mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 01:57:02 +00:00
Compare commits
581 Commits
1.0-rc2
...
v3.0.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c480fcb341 | ||
|
|
e74f5e5116 | ||
|
|
6ce045ddb7 | ||
|
|
9b03c311db | ||
|
|
ebd25a60e1 | ||
|
|
7c87649579 | ||
|
|
e56365b9a0 | ||
|
|
698b0406c8 | ||
|
|
361bbec293 | ||
|
|
407ae87a3b | ||
|
|
4648d8e593 | ||
|
|
5c5b0f819b | ||
|
|
8adfd82232 | ||
|
|
2162a4caaa | ||
|
|
cf4c5a29bb | ||
|
|
6598b09114 | ||
|
|
ce5bfddaa5 | ||
|
|
2934d799ef | ||
|
|
8a07a24828 | ||
|
|
2408ace6c2 | ||
|
|
1a5db5b5f8 | ||
|
|
f712f5b0f3 | ||
|
|
f0520b88c5 | ||
|
|
5a0c4778cb | ||
|
|
289b38f016 | ||
|
|
316a732e7f | ||
|
|
f0bc5741f3 | ||
|
|
046de42774 | ||
|
|
860045715c | ||
|
|
001e9fec58 | ||
|
|
2e0b7cc097 | ||
|
|
b0bbf2e9f5 | ||
|
|
3372ade61b | ||
|
|
62dd540be5 | ||
|
|
65a6ab2b4f | ||
|
|
9e1da20782 | ||
|
|
930801f6da | ||
|
|
4fc8d229eb | ||
|
|
e8e506f870 | ||
|
|
8a3d291ff3 | ||
|
|
107b649738 | ||
|
|
c91a3ecd41 | ||
|
|
2c74268014 | ||
|
|
da63e4d77a | ||
|
|
4a90b37815 | ||
|
|
cabbdacb89 | ||
|
|
baad19e838 | ||
|
|
c520af4983 | ||
|
|
c312909112 | ||
|
|
083b65c9bc | ||
|
|
59ae2112f7 | ||
|
|
1a45179e31 | ||
|
|
2b857245f7 | ||
|
|
9573504725 | ||
|
|
c21066752f | ||
|
|
66c95baf05 | ||
|
|
22a7789b7b | ||
|
|
d2da53cc0f | ||
|
|
bfac9a0cc2 | ||
|
|
a64429ae61 | ||
|
|
2436f2e3de | ||
|
|
fc76ce74cb | ||
|
|
eef4bbdb01 | ||
|
|
201987f6a8 | ||
|
|
45d9d2af39 | ||
|
|
12802e93cb | ||
|
|
0956a13618 | ||
|
|
de4bb991dd | ||
|
|
14f0895ae7 | ||
|
|
8bb01570ef | ||
|
|
3a1c757d04 | ||
|
|
d8474d56e5 | ||
|
|
eef45a4473 | ||
|
|
91d19308fe | ||
|
|
e359c0b030 | ||
|
|
a73ffeabd3 | ||
|
|
0b3f4eab75 | ||
|
|
7ea4798e77 | ||
|
|
070de88bba | ||
|
|
383386d5fb | ||
|
|
a3e2c5247e | ||
|
|
702f9ef48e | ||
|
|
ac4501ba35 | ||
|
|
c78656b400 | ||
|
|
b6a6cc6708 | ||
|
|
a17fa256a2 | ||
|
|
7ea8e2fc03 | ||
|
|
a0b6f0692d | ||
|
|
c1b7e3605c | ||
|
|
2b3dd51e71 | ||
|
|
e4c48a0705 | ||
|
|
5c885a067a | ||
|
|
71b3de59af | ||
|
|
fc95f6e57f | ||
|
|
e5e1c39097 | ||
|
|
f1b85be23a | ||
|
|
aaf8eb5ec1 | ||
|
|
18b85877ab | ||
|
|
cd89d77d9f | ||
|
|
a54e0f2438 | ||
|
|
3738ccc11d | ||
|
|
a6cdd30fb1 | ||
|
|
a467829103 | ||
|
|
b2255fefab | ||
|
|
34799b9a04 | ||
|
|
cfbd30d8b0 | ||
|
|
7cd45ff3c7 | ||
|
|
3c2b302a5f | ||
|
|
35969e0b0f | ||
|
|
d933d591d8 | ||
|
|
b82cafc338 | ||
|
|
f739ba90a1 | ||
|
|
6724c86181 | ||
|
|
645feeaf85 | ||
|
|
99df27ee34 | ||
|
|
5ae69314dd | ||
|
|
3df655d611 | ||
|
|
ae76ecef00 | ||
|
|
37f05b08c5 | ||
|
|
79fdc58567 | ||
|
|
d16699f59f | ||
|
|
9ca179e249 | ||
|
|
07e75495e8 | ||
|
|
683ce1241e | ||
|
|
9815a66575 | ||
|
|
8e04ce1fec | ||
|
|
6287f5fe9c | ||
|
|
f47e2cfcc2 | ||
|
|
dbdc60a0fb | ||
|
|
c0a878db47 | ||
|
|
0ea18cbe2b | ||
|
|
986377b531 | ||
|
|
fac44b7753 | ||
|
|
c977ded5ba | ||
|
|
c2109a8df0 | ||
|
|
321c5615a5 | ||
|
|
c57260349b | ||
|
|
91f3e08ce5 | ||
|
|
c762c4d7a1 | ||
|
|
b954bea7c6 | ||
|
|
362ece171a | ||
|
|
1922dc145d | ||
|
|
4b9e432730 | ||
|
|
78b36b0b14 | ||
|
|
2f7194835d | ||
|
|
7c213cd897 | ||
|
|
6189b56b79 | ||
|
|
1a8b5184cd | ||
|
|
55e11929c7 | ||
|
|
4dd3ad33f9 | ||
|
|
92c89b98ee | ||
|
|
51d732fa20 | ||
|
|
ffaf296faa | ||
|
|
af3ed04100 | ||
|
|
caec5e7c17 | ||
|
|
862aff434e | ||
|
|
6ef2beb821 | ||
|
|
6fd13a5215 | ||
|
|
77ea66e0e6 | ||
|
|
89886843bd | ||
|
|
4894244d5c | ||
|
|
d627da2038 | ||
|
|
348eebe418 | ||
|
|
bc427e237f | ||
|
|
f12a72871f | ||
|
|
efe554bd77 | ||
|
|
ecd7c9f6e6 | ||
|
|
f0c9a1e7f4 | ||
|
|
1eba3164b5 | ||
|
|
aae41ab79a | ||
|
|
fc5cc4d864 | ||
|
|
9b3049562d | ||
|
|
7a16f64ff0 | ||
|
|
6a95dfe5c6 | ||
|
|
22884378f3 | ||
|
|
0cf97f5c58 | ||
|
|
4eb2d5ae97 | ||
|
|
ce59c87250 | ||
|
|
6215ce77dd | ||
|
|
ba8e2de475 | ||
|
|
0f9585a52b | ||
|
|
e71377f966 | ||
|
|
0c9cf81c94 | ||
|
|
ff7fb8a781 | ||
|
|
6619764ea2 | ||
|
|
0d9952d35f | ||
|
|
5be2b57a12 | ||
|
|
0ed0cd2d64 | ||
|
|
74e7fd1179 | ||
|
|
eaf41949d4 | ||
|
|
59062e1326 | ||
|
|
019bd11309 | ||
|
|
cf265dbe2c | ||
|
|
c77cae2429 | ||
|
|
abd9ebeb35 | ||
|
|
f2cd3f92da | ||
|
|
e70900dd1a | ||
|
|
284dd70bc6 | ||
|
|
fe20854173 | ||
|
|
5ccdece541 | ||
|
|
b67d5ba376 | ||
|
|
ecd0c124d4 | ||
|
|
ac3a94412d | ||
|
|
8017e0ce57 | ||
|
|
1f2d1a4622 | ||
|
|
bea3849c97 | ||
|
|
2bbcef072a | ||
|
|
d1954cdd6f | ||
|
|
c92cd309bc | ||
|
|
d05eb10851 | ||
|
|
9e2f138279 | ||
|
|
af9c735cd7 | ||
|
|
5328406533 | ||
|
|
54ca36c442 | ||
|
|
5ab273b7b8 | ||
|
|
352e8c396d | ||
|
|
f0b4dda8e6 | ||
|
|
c8dabf8593 | ||
|
|
852b9fce26 | ||
|
|
a8795f46dc | ||
|
|
bcf0cafb34 | ||
|
|
04a3f58e6d | ||
|
|
7dade7f0e4 | ||
|
|
523c04a522 | ||
|
|
7a5459ce08 | ||
|
|
dd14fb9989 | ||
|
|
c5b5321be3 | ||
|
|
8b5a05eb40 | ||
|
|
60db73b813 | ||
|
|
ef633b906c | ||
|
|
62406f05e8 | ||
|
|
2e49b591eb | ||
|
|
7d7e408bfa | ||
|
|
91fea4e1fd | ||
|
|
469132fe2f | ||
|
|
690b19a22c | ||
|
|
30e3892cb0 | ||
|
|
fcb4bc20e0 | ||
|
|
8a3d2ff6a2 | ||
|
|
2f67a6922a | ||
|
|
82e6a9fe5d | ||
|
|
1a7e47b60a | ||
|
|
4d17886ed6 | ||
|
|
0640f9fa08 | ||
|
|
f68f07d896 | ||
|
|
98745b3bb9 | ||
|
|
5e8f00fe0b | ||
|
|
f9adced65e | ||
|
|
dc007da82e | ||
|
|
bf12925fd1 | ||
|
|
0d7c94c531 | ||
|
|
00dad88bde | ||
|
|
fdcb29a52b | ||
|
|
894db04d70 | ||
|
|
58c3e17be7 | ||
|
|
30d8d6c64f | ||
|
|
37361e08e8 | ||
|
|
2ceb510f70 | ||
|
|
62fa52f42c | ||
|
|
568474da32 | ||
|
|
562332258d | ||
|
|
941b682197 | ||
|
|
7fc5e06517 | ||
|
|
704ba04c51 | ||
|
|
a291944a7d | ||
|
|
7afc49d88f | ||
|
|
8a30b9717e | ||
|
|
61ac42379e | ||
|
|
1ed8723bd7 | ||
|
|
bcede38453 | ||
|
|
7e71b4a42c | ||
|
|
e8f57fb4ed | ||
|
|
d846f04186 | ||
|
|
7a783efd5d | ||
|
|
a40c92d221 | ||
|
|
c6bb7d1d4d | ||
|
|
12f98868ba | ||
|
|
bf152adbef | ||
|
|
ff2121fbb9 | ||
|
|
939ff19991 | ||
|
|
c2e1d58b90 | ||
|
|
8c66a24513 | ||
|
|
d1dbcf6e63 | ||
|
|
064549f576 | ||
|
|
a7d2b00a9c | ||
|
|
0b3c18eba9 | ||
|
|
c695a2690d | ||
|
|
dc02092cf6 | ||
|
|
cc5cd7f9c3 | ||
|
|
e3be5362dc | ||
|
|
3fe4140733 | ||
|
|
7b073562eb | ||
|
|
2c813081eb | ||
|
|
acd48773da | ||
|
|
87bab63aa2 | ||
|
|
47b526824b | ||
|
|
5a28e9f9e7 | ||
|
|
deceb29377 | ||
|
|
e076f80416 | ||
|
|
92f2da5025 | ||
|
|
cbdb96a421 | ||
|
|
6601d156be | ||
|
|
779b1e63af | ||
|
|
6f25a1320e | ||
|
|
e2ba10c8ca | ||
|
|
64ecd810b4 | ||
|
|
393a5b8d4e | ||
|
|
0055b84916 | ||
|
|
7ab9ea92ad | ||
|
|
06989c8218 | ||
|
|
23dc357202 | ||
|
|
2d09e94f87 | ||
|
|
86c8ccf9ea | ||
|
|
ce2339ee9c | ||
|
|
69a0d355f9 | ||
|
|
04bb842549 | ||
|
|
b50c926745 | ||
|
|
0d6936d1b3 | ||
|
|
412b62d6fa | ||
|
|
b42ea130ea | ||
|
|
281cc21412 | ||
|
|
bb0fb410c1 | ||
|
|
3c7c24b698 | ||
|
|
f0a94f4848 | ||
|
|
845cbedc0d | ||
|
|
be940dd2bc | ||
|
|
e7a86bd8f8 | ||
|
|
6d250a47b4 | ||
|
|
e5785e81aa | ||
|
|
30f5c163e4 | ||
|
|
0d0e07da70 | ||
|
|
5cb07d5780 | ||
|
|
30a329c0d3 | ||
|
|
f953bcf4bb | ||
|
|
a22412f65e | ||
|
|
dc4db59211 | ||
|
|
e84b1f8952 | ||
|
|
b9003a9328 | ||
|
|
469a778d81 | ||
|
|
d132efd6fa | ||
|
|
9b3d3c2ea8 | ||
|
|
5e13e50763 | ||
|
|
519779b7ba | ||
|
|
2cc0da3093 | ||
|
|
9be4eec31c | ||
|
|
219862797e | ||
|
|
10bc07a18e | ||
|
|
3bc66d2788 | ||
|
|
487e491d6f | ||
|
|
12ad0cb3f0 | ||
|
|
137466f89b | ||
|
|
d9b5e40c87 | ||
|
|
9c18f1fb30 | ||
|
|
a58dfc5e4f | ||
|
|
98d8288244 | ||
|
|
1f56e06fb9 | ||
|
|
0016c6d5df | ||
|
|
94167fa313 | ||
|
|
f8c30a678a | ||
|
|
68cfd33fbe | ||
|
|
f00b9ddef0 | ||
|
|
019e66d798 | ||
|
|
a3877e4f4b | ||
|
|
a89b6bbca2 | ||
|
|
f58e51d921 | ||
|
|
c39d5ded3f | ||
|
|
5f5a7eecd3 | ||
|
|
1ff8ffe9bd | ||
|
|
b4abdb6675 | ||
|
|
ffa756d895 | ||
|
|
d5aea4d635 | ||
|
|
3c19e0820b | ||
|
|
3b9af85924 | ||
|
|
26c38b1148 | ||
|
|
3062b70ea0 | ||
|
|
01d51302c4 | ||
|
|
dedd0eacff | ||
|
|
6431b71cfe | ||
|
|
25815767a7 | ||
|
|
06165cb3d3 | ||
|
|
ac21675bd7 | ||
|
|
f62eaa31b9 | ||
|
|
fcc0848bc0 | ||
|
|
dec67622ba | ||
|
|
11033a5359 | ||
|
|
3a4fd3f093 | ||
|
|
cc47895d71 | ||
|
|
76a77beca9 | ||
|
|
81eba8f51a | ||
|
|
c750dcff81 | ||
|
|
de75b778c0 | ||
|
|
0e72b414c4 | ||
|
|
52de1b2bfe | ||
|
|
b28e1d86d8 | ||
|
|
f4fadd7e44 | ||
|
|
28b40eb94d | ||
|
|
0f67e51e56 | ||
|
|
9d68e2c0a5 | ||
|
|
5bca2ae738 | ||
|
|
10cce5c751 | ||
|
|
8040b07c28 | ||
|
|
4e8c287608 | ||
|
|
6f57c51934 | ||
|
|
a457b65603 | ||
|
|
890d8e0202 | ||
|
|
9f6dee640e | ||
|
|
095796c8a1 | ||
|
|
db358fea8c | ||
|
|
86554a63b8 | ||
|
|
1efe2ba48f | ||
|
|
f597687aa3 | ||
|
|
6368e3a83b | ||
|
|
ef17ad9074 | ||
|
|
7908e41212 | ||
|
|
a9b174bcb6 | ||
|
|
dc0b9b325e | ||
|
|
0a967f0f21 | ||
|
|
304a2260e8 | ||
|
|
9fc9a333a6 | ||
|
|
3e8b914f89 | ||
|
|
5e94f7b750 | ||
|
|
64e75face8 | ||
|
|
be4675b374 | ||
|
|
6722811425 | ||
|
|
56cff6296a | ||
|
|
a1e35009e0 | ||
|
|
cc0428ab9b | ||
|
|
68e35b5eca | ||
|
|
5147865e55 | ||
|
|
a3727ee436 | ||
|
|
9e49480482 | ||
|
|
958f5a44d9 | ||
|
|
9b1d28fc7d | ||
|
|
e6f95c9367 | ||
|
|
de5b9a824b | ||
|
|
9ac3c90036 | ||
|
|
85e3a77fe5 | ||
|
|
32631daeae | ||
|
|
62b77dfe5e | ||
|
|
fa28fa57f8 | ||
|
|
00e4d3791c | ||
|
|
cf7bdee925 | ||
|
|
83b71334c2 | ||
|
|
aa0edff398 | ||
|
|
f09a4558a9 | ||
|
|
1137912e1d | ||
|
|
e14e97919f | ||
|
|
9ae428ca44 | ||
|
|
c346372c85 | ||
|
|
63a76696bf | ||
|
|
86365a047c | ||
|
|
e5aad9f5d0 | ||
|
|
e32e6d00d6 | ||
|
|
b3db619db9 | ||
|
|
6ca2c27747 | ||
|
|
88ccb19360 | ||
|
|
a222b7c428 | ||
|
|
0e02061c4a | ||
|
|
c8fd56ca90 | ||
|
|
d8255382da | ||
|
|
61e9b91637 | ||
|
|
fa6b0a3227 | ||
|
|
61c6e7c2e9 | ||
|
|
dcc0f36f48 | ||
|
|
c1e7de5e14 | ||
|
|
493efb04cd | ||
|
|
ff481c56c6 | ||
|
|
2f5d80a521 | ||
|
|
364e7a0b80 | ||
|
|
26a9903492 | ||
|
|
264fa9e11d | ||
|
|
1906a147f0 | ||
|
|
145e71dc43 | ||
|
|
017a097588 | ||
|
|
01ea89e7e2 | ||
|
|
8f14ba8359 | ||
|
|
8d36aa9750 | ||
|
|
02eee0a608 | ||
|
|
124f830478 | ||
|
|
1856deae46 | ||
|
|
a939605d4a | ||
|
|
f84b36039e | ||
|
|
5ef3adc88e | ||
|
|
6d2c2c8dad | ||
|
|
ebde99949e | ||
|
|
0afaf5109e | ||
|
|
7adf3741d3 | ||
|
|
9821095977 | ||
|
|
221a65cbe6 | ||
|
|
c421e4e0eb | ||
|
|
0068937d58 | ||
|
|
268f950e01 | ||
|
|
1742bf9a34 | ||
|
|
470ef9b496 | ||
|
|
f16debebbf | ||
|
|
ff460309e5 | ||
|
|
d13ebe9149 | ||
|
|
6fd9f1f98c | ||
|
|
3481a89533 | ||
|
|
e99baf766b | ||
|
|
629bb65cec | ||
|
|
42b3ba35cb | ||
|
|
c2779ff054 | ||
|
|
22eb793f61 | ||
|
|
d566da0d86 | ||
|
|
c172ea4dd7 | ||
|
|
511e014a90 | ||
|
|
86861eb747 | ||
|
|
2092949dbc | ||
|
|
798b320769 | ||
|
|
fd59aad4f0 | ||
|
|
ee5f75631a | ||
|
|
8e66be63f5 | ||
|
|
28adcf0437 | ||
|
|
4b4121bb3a | ||
|
|
3a7be7d89c | ||
|
|
e4d12e60aa | ||
|
|
982ad857e8 | ||
|
|
075aa0dd38 | ||
|
|
3f654e48be | ||
|
|
bbfdd4216f | ||
|
|
5e15300d02 | ||
|
|
5daa40b081 | ||
|
|
3eb2b5ce19 | ||
|
|
f36e21a65c | ||
|
|
5da5d3e509 | ||
|
|
7b8161042c | ||
|
|
b9535c126c | ||
|
|
8e084b5697 | ||
|
|
0b32af5495 | ||
|
|
f996fa2ae7 | ||
|
|
9fafbf73d0 | ||
|
|
a2606a14f6 | ||
|
|
30fe43dcc7 | ||
|
|
bcc36ee965 | ||
|
|
44f6407de8 | ||
|
|
e6b7e335cb | ||
|
|
83f9c6aa49 | ||
|
|
ef5a10457e | ||
|
|
e72c5c4b92 | ||
|
|
0f5ba4a7a9 | ||
|
|
c263129711 | ||
|
|
b107cb8787 | ||
|
|
44ff0be01e | ||
|
|
3b0d17867e | ||
|
|
4d8aa340ff | ||
|
|
5760d72bcd | ||
|
|
8e6745b8b7 | ||
|
|
517c68a6b7 | ||
|
|
4144e2b6f0 | ||
|
|
5623ac1e8e | ||
|
|
da28a8ee55 | ||
|
|
f48704b6a6 | ||
|
|
7b8b872ef0 | ||
|
|
028ac2994b | ||
|
|
834efefe46 | ||
|
|
affcc01d19 | ||
|
|
eadc61605a | ||
|
|
9ca1711252 | ||
|
|
bb398498d4 | ||
|
|
cb487c3c7b | ||
|
|
9ebdb06a7a | ||
|
|
8f5ac7ba5b | ||
|
|
88ae99abb0 | ||
|
|
63084c67b9 | ||
|
|
428dc6a286 | ||
|
|
9791039aea | ||
|
|
dcb6a6f845 | ||
|
|
7433e9ac36 | ||
|
|
c34549e783 | ||
|
|
13868d85d4 | ||
|
|
8f10e18fef | ||
|
|
b105bbf87f | ||
|
|
ad85a1db93 | ||
|
|
d418f4ba29 | ||
|
|
d98e6a2b58 | ||
|
|
8b7fbfda6a | ||
|
|
46fa714cc7 | ||
|
|
414ed44882 | ||
|
|
2e1c937d23 | ||
|
|
df11575b3a |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.dockerignore
|
||||||
|
.goxc.json
|
||||||
|
.goxc.local.json
|
||||||
|
.travis.yml
|
||||||
|
inbucket
|
||||||
|
inbucket.exe
|
||||||
|
swaks-tests
|
||||||
|
target
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
ui/dist
|
||||||
|
ui/elm-stuff
|
||||||
|
ui/node_modules
|
||||||
15
.github/workflows/docker-build.yml
vendored
Normal file
15
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: Docker Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master", "develop" ]
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: docker/build-push-action@v1
|
||||||
|
with:
|
||||||
|
repository: inbucket/inbucket
|
||||||
|
push: false
|
||||||
|
tag_with_ref: true
|
||||||
44
.github/workflows/release.yml
vendored
Normal file
44
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Build and Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master", "develop" ]
|
||||||
|
tags: [ "v*" ]
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: 1.15
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: '10.x'
|
||||||
|
- name: Setup Elm
|
||||||
|
uses: jorelali/setup-elm@v2
|
||||||
|
with:
|
||||||
|
elm-version: 0.19.1
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
working-directory: ./ui
|
||||||
|
- name: Test build release
|
||||||
|
uses: goreleaser/goreleaser-action@v2
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --snapshot
|
||||||
|
- name: Build and publish release
|
||||||
|
uses: goreleaser/goreleaser-action@v2
|
||||||
|
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -21,12 +21,34 @@ _testmain.go
|
|||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# vim swp files
|
# vim files
|
||||||
*.swp
|
*.swp
|
||||||
|
*.swo
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
|
||||||
# our binary
|
# Desktop Services Store on macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Inbucket binaries
|
||||||
|
/client
|
||||||
|
/client.exe
|
||||||
/inbucket
|
/inbucket
|
||||||
/inbucket.exe
|
/inbucket.exe
|
||||||
|
/dist/**
|
||||||
|
/cmd/client/client
|
||||||
|
/cmd/client/client.exe
|
||||||
|
/cmd/inbucket/inbucket
|
||||||
|
/cmd/inbucket/inbucket.exe
|
||||||
|
|
||||||
# local goxc config
|
# Elm UI
|
||||||
.goxc.local.json
|
# elm-package generated files
|
||||||
|
/ui/index.html
|
||||||
|
/ui/elm-stuff
|
||||||
|
/ui/tests/elm-stuff
|
||||||
|
# elm-repl generated files
|
||||||
|
repl-temp-*
|
||||||
|
# Distribution
|
||||||
|
/ui/dist/
|
||||||
|
# Dependency directories
|
||||||
|
/ui/node_modules
|
||||||
|
|||||||
89
.goreleaser.yml
Normal file
89
.goreleaser.yml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
project_name: inbucket
|
||||||
|
|
||||||
|
release:
|
||||||
|
github:
|
||||||
|
owner: inbucket
|
||||||
|
name: inbucket
|
||||||
|
name_template: '{{.Tag}}'
|
||||||
|
|
||||||
|
brews:
|
||||||
|
- commit_author:
|
||||||
|
name: goreleaserbot
|
||||||
|
email: goreleaser@carlosbecker.com
|
||||||
|
install: bin.install ""
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod download
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: inbucket
|
||||||
|
binary: inbucket
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
main: ./cmd/inbucket
|
||||||
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
|
- id: inbucket-client
|
||||||
|
binary: inbucket-client
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
main: ./cmd/client
|
||||||
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: tarball
|
||||||
|
format: tar.gz
|
||||||
|
wrap_in_directory: true
|
||||||
|
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||||
|
.Arm }}{{ end }}'
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
files:
|
||||||
|
- LICENSE*
|
||||||
|
- README*
|
||||||
|
- CHANGELOG*
|
||||||
|
- etc/**/*
|
||||||
|
- ui/dist/**/*
|
||||||
|
- ui/greeting.html
|
||||||
|
|
||||||
|
nfpms:
|
||||||
|
- formats:
|
||||||
|
- deb
|
||||||
|
- rpm
|
||||||
|
vendor: inbucket.org
|
||||||
|
homepage: https://www.inbucket.org/
|
||||||
|
maintainer: github@hillyerd.com
|
||||||
|
description: All-in-one disposable webmail service.
|
||||||
|
license: MIT
|
||||||
|
files:
|
||||||
|
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
|
||||||
|
config_files:
|
||||||
|
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||||
|
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
name_template: SNAPSHOT-{{ .Commit }}
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||||
|
|
||||||
|
dist: dist
|
||||||
13
.goxc.json
13
.goxc.json
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"TasksExclude": [
|
|
||||||
"pkg-build"
|
|
||||||
],
|
|
||||||
"Arch": "amd64",
|
|
||||||
"Os": "darwin freebsd linux windows",
|
|
||||||
"Resources": {
|
|
||||||
"Include": "README*,LICENSE*,inbucket.bat,etc,themes"
|
|
||||||
},
|
|
||||||
"PackageVersion": "1.0",
|
|
||||||
"PrereleaseInfo": "rc2",
|
|
||||||
"FormatVersion": "0.8"
|
|
||||||
}
|
|
||||||
33
.travis.yml
33
.travis.yml
@@ -1,9 +1,30 @@
|
|||||||
|
dist: bionic
|
||||||
|
|
||||||
|
env:
|
||||||
|
global:
|
||||||
|
- GO111MODULE=on
|
||||||
|
|
||||||
language: go
|
language: go
|
||||||
|
|
||||||
go:
|
|
||||||
- 1.1
|
|
||||||
- tip
|
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- go get -v ./...
|
- "go get golang.org/x/lint/golint"
|
||||||
- go get github.com/stretchr/testify
|
- "make deps"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
include:
|
||||||
|
- go: "1.14.x"
|
||||||
|
- go: "1.15.x"
|
||||||
|
- language: elm
|
||||||
|
elm: "latest-0.19.1"
|
||||||
|
elm_format: "latest-0.19.1"
|
||||||
|
elm_test: "latest-0.19.1"
|
||||||
|
node_js: "10.16.0"
|
||||||
|
install:
|
||||||
|
- "cd ui"
|
||||||
|
- "npm ci"
|
||||||
|
script:
|
||||||
|
- "elm-format --validate ."
|
||||||
|
- "npm run build"
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
|||||||
274
CHANGELOG.md
Normal file
274
CHANGELOG.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
Change Log
|
||||||
|
==========
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [v3.0.0-rc1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Refresh button to reload mailbox contents
|
||||||
|
- Improved keyboard (tab) focus highlights
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The UI now includes the Open Sans webfont instead of relying on browser/OS
|
||||||
|
fonts
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta3]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker `HEALTHCHECK`
|
||||||
|
- Mouse-out delay to improve pop-up menu navigation
|
||||||
|
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated frontend and backend dependencies, Docker image base
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved layout on mobile and wide displays
|
||||||
|
- Prevent unexpected input for modal dialogs
|
||||||
|
- Allow empty SMTP `MAIL FROM:<>`
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ability to name mailboxes after domain of email recipient, set via
|
||||||
|
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated JavaScript dependencies.
|
||||||
|
- Updated Go dependencies.
|
||||||
|
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- URLs to view/download attachments from REST API, #138
|
||||||
|
- Support for late EHLO, #141
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `posix-millis` field to REST message and header responses for easier date
|
||||||
|
parsing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Rewrote the user interface from scratch, it's now an Elm powered single page
|
||||||
|
application.
|
||||||
|
- Moved the Inbucket repository to its own GitHub organization.
|
||||||
|
- Update to enmime v0.5.0
|
||||||
|
|
||||||
|
|
||||||
|
## v2.1.0
|
||||||
|
|
||||||
|
No change from beta1.
|
||||||
|
|
||||||
|
|
||||||
|
## [v2.1.0-beta1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Use Go 1.11 modules for reproducible builds.
|
||||||
|
- SMTP TLS support (thanks kingforaday.)
|
||||||
|
- `INBUCKET_WEB_PPROF` configuration option for performance profiling.
|
||||||
|
- Godoc example for the REST API client.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Docker build now uses Go 1.11 and Alpine 3.8
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Render UTF-8 addresses correctly in both REST API and Web UI.
|
||||||
|
- Memory storage now correctly returns the newest message when asked for ID
|
||||||
|
`latest`.
|
||||||
|
|
||||||
|
|
||||||
|
## [v2.0.0] - 2018-05-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
|
||||||
|
- Disabled color log output on Windows, doesn't work there.
|
||||||
|
|
||||||
|
|
||||||
|
## [v2.0.0-rc1] - 2018-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Inbucket is now configured using environment variables instead of a config
|
||||||
|
file.
|
||||||
|
- In-memory storage option, best for small installations and desktops. Will be
|
||||||
|
used by default.
|
||||||
|
- Storage type is now displayed on Status page.
|
||||||
|
- Store size is now calculated during retention scan and displayed on the Status
|
||||||
|
page.
|
||||||
|
- Debian `.deb` package generation to release process.
|
||||||
|
- RedHat `.rpm` package generation to release process.
|
||||||
|
- Message seen flag in REST and Web UI so you can see which messages have
|
||||||
|
already been read.
|
||||||
|
- Recipient domain accept policy; Inbucket can now reject mail to specific
|
||||||
|
domains.
|
||||||
|
- Configurable support for identifying a mailbox by full email address instead
|
||||||
|
of just the local part (username).
|
||||||
|
- Friendly URL support: `<inbucket-url>/<mailbox>` will redirect your browser to
|
||||||
|
that mailbox.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Massive refactor of back-end code. Inbucket should now be both easier and
|
||||||
|
more enjoyable to work on.
|
||||||
|
- Changes to file storage format, will require pre-2.0 mail store directories to
|
||||||
|
be deleted.
|
||||||
|
- Renamed `themes` directory to `ui` and eliminated the intermediate `bootstrap`
|
||||||
|
directory.
|
||||||
|
- Docker build:
|
||||||
|
- Uses the same default ports as other builds; smtp:2500 http:9000 pop3:1100
|
||||||
|
- Uses volume `/config` for `greeting.html`
|
||||||
|
- Uses volume `/storage` for mail storage
|
||||||
|
- Log output is now structured, and will be output as JSON with the `-logjson`
|
||||||
|
flag; which is enabled by default for the Docker container.
|
||||||
|
- SMTP and POP3 network tracing is no longer logged regardless of level, but can
|
||||||
|
be sent to stdout via `-netdebug` flag.
|
||||||
|
- Replaced store/nostore config variables with a storage policy that mirrors the
|
||||||
|
domain accept policy.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- No longer support SIGHUP or log file rotation.
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.3.1] - 2018-03-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Adding additional locking during message delivery to prevent race condition
|
||||||
|
that could lose messages.
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.3.0] - 2018-02-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Button to purge mailbox contents from the UI.
|
||||||
|
- Simple HTML/CSS sanitization; `Safe HTML` and `Plain Text` UI tabs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Reverse message display sort order in the UI; now newest first.
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.2.0] - 2017-12-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- No significant code changes from rc2
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||||
|
provides a more natural API
|
||||||
|
- Powerful command line REST
|
||||||
|
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
|
||||||
|
- Allow use of `latest` as a message ID in REST calls
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `rest/client.NewV1` renamed to `New`
|
||||||
|
- `rest/client` package now embeds the shared `rest/model` structs into its own
|
||||||
|
types
|
||||||
|
- Fixed panic when `monitor.history` set to 0
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.2.0-rc1] - 2017-01-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||||
|
- Attachment list to [GET message
|
||||||
|
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
|
||||||
|
- [Go client for REST
|
||||||
|
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
|
||||||
|
- Monitor feature: lists messages as they arrive, regardless of their
|
||||||
|
destination mailbox
|
||||||
|
- Make `@inbucket` mailbox prompt configurable
|
||||||
|
- Warnings and errors from MIME parser are displayed with message
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- No longer run out of file handles when dealing with a large number of
|
||||||
|
recipients for a single message.
|
||||||
|
- Empty intermediate directories are now removed when a mailbox is deleted,
|
||||||
|
leaving less junk on your filesystem.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Build now requires Go 1.7 or later
|
||||||
|
- Removed legacy `integral` theme, as most new features only in `bootstrap`
|
||||||
|
- Removed old RESTful APIs, must use `/api/v1` base URI now
|
||||||
|
- Allow increased local-part length of 128 chars for Mailgun
|
||||||
|
- RedHat and Ubuntu now use systemd instead of legacy init systems
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.1.0] - 2016-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Homebrew inbucket.conf and formula (see README)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Log and continue when unable to delete oldest message during cap enforcement
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.1.0-rc2] - 2016-03-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Message Cap to status page
|
||||||
|
- Search-while-you-type message list filter
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Shutdown hang in retention scanner
|
||||||
|
- Display empty subject as `(No Subject)`
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.1.0-rc1] - 2016-03-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Inbucket now builds with Go 1.5 or 1.6
|
||||||
|
- Project can build & run inside a Docker container
|
||||||
|
- Add new default theme based on Bootstrap
|
||||||
|
- Your recently accessed mailboxes are listed in the GUI
|
||||||
|
- HTML-only messages now get down-converted to plain text automatically
|
||||||
|
- This change log
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- RESTful API moved to `/api/v1` base URI
|
||||||
|
- More graceful shutdown on Ctrl-C or when errors encountered
|
||||||
|
|
||||||
|
|
||||||
|
## [v1.0] - 2014-04-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add new configuration option `mailbox.message.cap` to prevent individual
|
||||||
|
mailboxes from growing too large.
|
||||||
|
- Add Link button to messages, allows for directing another person to a
|
||||||
|
specific message.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
||||||
|
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
|
||||||
|
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
|
||||||
|
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
|
||||||
|
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||||
|
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||||
|
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||||
|
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||||
|
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||||
|
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||||
|
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||||
|
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||||
|
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
|
||||||
|
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
|
||||||
|
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||||
|
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
|
||||||
|
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
|
||||||
|
|
||||||
|
|
||||||
|
## Release Checklist
|
||||||
|
|
||||||
|
1. Create release branch: `git flow release start 1.x.0`
|
||||||
|
2. Update CHANGELOG.md:
|
||||||
|
- Ensure *Unreleased* section is up to date
|
||||||
|
- Rename *Unreleased* section to release name and date.
|
||||||
|
- Add new GitHub `/compare` link
|
||||||
|
3. Run tests
|
||||||
|
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||||
|
5. Commit changes and merge release: `git flow release finish`
|
||||||
|
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
|
||||||
|
complete
|
||||||
|
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||||
|
|
||||||
|
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||||
44
CONTRIBUTING.md
Normal file
44
CONTRIBUTING.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
How to Contribute
|
||||||
|
=================
|
||||||
|
|
||||||
|
Inbucket encourages third-party patches. It's valuable to know how other
|
||||||
|
developers are using the product.
|
||||||
|
|
||||||
|
**tl;dr:** File pull requests against the `develop` branch, not `master`!
|
||||||
|
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
If you anticipate your issue requiring a large patch, please first submit a
|
||||||
|
GitHub issue describing the problem or feature. You are also encouraged to
|
||||||
|
outline the process you would like to use to resolve the issue. I will attempt
|
||||||
|
to provide validation and/or guidance on your suggested approach.
|
||||||
|
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
Inbucket uses [git-flow] with default options. If you have git-flow installed,
|
||||||
|
you can run `git flow feature start <topic branch name>`.
|
||||||
|
|
||||||
|
Without git-flow, create a topic branch from where you want to base your work:
|
||||||
|
- This is usually the `develop` branch, example command:
|
||||||
|
`git checkout origin/develop -b <topic branch name>`
|
||||||
|
- Only target the `master` branch if the issue is already resolved in
|
||||||
|
`develop`.
|
||||||
|
|
||||||
|
Once you are on your topic branch:
|
||||||
|
|
||||||
|
1. Make commits of logical units.
|
||||||
|
2. Add unit tests to exercise your changes.
|
||||||
|
3. Run the updated code through `go fmt` and `go vet`.
|
||||||
|
4. Ensure the code builds and tests with the following commands:
|
||||||
|
- `go clean ./...`
|
||||||
|
- `go build ./...`
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Thank you for contributing to Inbucket!
|
||||||
|
|
||||||
|
[git-flow]: https://github.com/nvie/gitflow
|
||||||
59
Dockerfile
Normal file
59
Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||||
|
|
||||||
|
# Install build-time dependencies
|
||||||
|
FROM golang:1.15-alpine3.12 as builder
|
||||||
|
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
ENV CGO_ENABLED 0
|
||||||
|
RUN make clean deps
|
||||||
|
WORKDIR /build/ui
|
||||||
|
RUN rm -rf dist elm-stuff node_modules
|
||||||
|
RUN npm ci
|
||||||
|
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
|
||||||
|
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
|
||||||
|
|
||||||
|
# Build server
|
||||||
|
WORKDIR /build
|
||||||
|
RUN go build -o inbucket \
|
||||||
|
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||||
|
-v ./cmd/inbucket
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
WORKDIR /build/ui
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Run in minimal image
|
||||||
|
FROM alpine:3.12
|
||||||
|
RUN apk --no-cache add tzdata
|
||||||
|
WORKDIR /opt/inbucket
|
||||||
|
RUN mkdir bin defaults ui
|
||||||
|
COPY --from=builder /build/inbucket bin
|
||||||
|
COPY --from=builder /build/ui/dist ui
|
||||||
|
COPY etc/docker/defaults/greeting.html defaults
|
||||||
|
COPY etc/docker/defaults/start-inbucket.sh /
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
ENV INBUCKET_SMTP_DISCARDDOMAINS bitbucket.local
|
||||||
|
ENV INBUCKET_SMTP_TIMEOUT 30s
|
||||||
|
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||||
|
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||||
|
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||||
|
ENV INBUCKET_WEB_UIDIR=ui
|
||||||
|
ENV INBUCKET_STORAGE_TYPE file
|
||||||
|
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||||
|
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||||
|
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
||||||
|
|
||||||
|
# Healthcheck
|
||||||
|
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'
|
||||||
|
|
||||||
|
# Ports: SMTP, HTTP, POP3
|
||||||
|
EXPOSE 2500 9000 1100
|
||||||
|
|
||||||
|
# Persistent Volumes
|
||||||
|
VOLUME /config
|
||||||
|
VOLUME /storage
|
||||||
|
|
||||||
|
ENTRYPOINT ["/start-inbucket.sh"]
|
||||||
|
CMD ["-logjson"]
|
||||||
40
Makefile
Normal file
40
Makefile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
SHELL = /bin/sh
|
||||||
|
|
||||||
|
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
||||||
|
PKGS := $(shell go list ./... | grep -v /vendor/)
|
||||||
|
|
||||||
|
.PHONY: all build clean fmt lint reflex simplify test
|
||||||
|
|
||||||
|
commands = client inbucket
|
||||||
|
|
||||||
|
all: clean test lint build
|
||||||
|
|
||||||
|
$(commands): %: cmd/%
|
||||||
|
go build ./$<
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean $(PKGS)
|
||||||
|
rm -f $(commands)
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
deps:
|
||||||
|
go get -t ./...
|
||||||
|
|
||||||
|
build: $(commands)
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
@gofmt -l -w $(SRC)
|
||||||
|
|
||||||
|
simplify:
|
||||||
|
@gofmt -s -l -w $(SRC)
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
||||||
|
@golint -set_exit_status $(PKGS)
|
||||||
|
@go vet $(PKGS)
|
||||||
|
|
||||||
|
reflex:
|
||||||
|
reflex -r '\.go$$' -- sh -c 'echo; date; echo; go test ./... && echo ALL PASS'
|
||||||
97
README.md
97
README.md
@@ -1,56 +1,89 @@
|
|||||||
Inbucket [](https://travis-ci.org/jhillyerd/inbucket)
|
Inbucket
|
||||||
========
|
=============================================================================
|
||||||
|
[][Build Status]
|
||||||
|
[][Docker Image]
|
||||||
|
|
||||||
Inbucket is an email testing service; it will accept messages for any email
|
Inbucket is an email testing service; it will accept messages for any email
|
||||||
address and make them available to view via a web interface.
|
address and make them available via web, REST and POP3 interfaces. Once
|
||||||
|
compiled, Inbucket does not have any external dependencies - HTTP, SMTP, POP3
|
||||||
|
and storage are all built in.
|
||||||
|
|
||||||
It allows web developers, software engineers and system administrators to
|
A Go client for the REST API is available in
|
||||||
quickly see the emailed output of ther applications. No per-account setup is
|
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
|
||||||
required! Mailboxes are created on the fly as mail is received for them, and
|
|
||||||
no password is required to browse the content of the mailboxes.
|
|
||||||
|
|
||||||
Inbucket has a built-in SMTP server and stores incoming mail as flat files on
|
Read more at the [Inbucket Website]
|
||||||
disk - no external SMTP or database daemons required.
|
|
||||||
|
|
||||||
There is also an embedded POP3 server, which allows message rendering to be
|

|
||||||
checked in multiple email programs or to verify message delivery as part of
|
|
||||||
an integration test suite.
|
|
||||||
|
|
||||||
Read more at the [Inbucket website](http://jhillyerd.github.io/inbucket/).
|
|
||||||
|
|
||||||
Development Status
|
## Development Status
|
||||||
------------------
|
|
||||||
|
|
||||||
Inbucket is currently release-candidate quality: it is being used for real work.
|
Inbucket is currently production quality: it is being used for real work.
|
||||||
|
|
||||||
Please check the [issues list](https://github.com/jhillyerd/inbucket/issues?state=open)
|
Please see the [Change Log] and [Issues List] for more details. If you'd like
|
||||||
for more details.
|
to contribute code to the project check out [CONTRIBUTING.md].
|
||||||
|
|
||||||
Installation from Source
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
You will need a functioning [Go installation][1] for this to work.
|
## Docker
|
||||||
|
|
||||||
Grab the Inbucket source code and compile the daemon:
|
Inbucket has automated [Docker Image] builds via Docker Hub. The `stable` tag
|
||||||
|
tracks our `master` branch (releases), `latest` tracks our unstable
|
||||||
|
`development` branch.
|
||||||
|
|
||||||
go get -v github.com/jhillyerd/inbucket
|
|
||||||
|
|
||||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
## Building from Source
|
||||||
Unix and OS X machines as is. Launch the daemon:
|
|
||||||
|
|
||||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
|
You will need functioning [Go] and [Node.js] installations for this to work.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/inbucket/inbucket.git
|
||||||
|
cd inbucket/ui
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
go build ./cmd/inbucket
|
||||||
|
```
|
||||||
|
|
||||||
|
For more information on building and development flows, check out the
|
||||||
|
[Development Quickstart] page of our wiki.
|
||||||
|
|
||||||
|
### Configure and Launch
|
||||||
|
|
||||||
|
Inbucket reads its configuration from environment variables, but comes with
|
||||||
|
reasonable defaults built-in. It should work on most Unix and OS X machines as
|
||||||
|
is. Launch the daemon:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./inbucket
|
||||||
|
```
|
||||||
|
|
||||||
By default the SMTP server will be listening on localhost port 2500 and
|
By default the SMTP server will be listening on localhost port 2500 and
|
||||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||||
|
|
||||||
There are RedHat EL6 init, logrotate and httpd proxy configs provided.
|
See doc/[config.md] for more information on configuring Inbucket, but you will
|
||||||
|
likely find the [Configurator] tool the easiest way to generate a configuration.
|
||||||
|
|
||||||
About
|
|
||||||
-----
|
|
||||||
|
|
||||||
Inbucket is written in [Google Go][1].
|
## About
|
||||||
|
|
||||||
|
Inbucket is written in [Go] and [Elm].
|
||||||
|
|
||||||
Inbucket is open source software released under the MIT License. The latest
|
Inbucket is open source software released under the MIT License. The latest
|
||||||
version can be found at https://github.com/jhillyerd/inbucket
|
version can be found at https://github.com/inbucket/inbucket
|
||||||
|
|
||||||
[1]: http://golang.org/
|
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||||
|
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
|
||||||
|
[config.md]: https://github.com/inbucket/inbucket/blob/master/doc/config.md
|
||||||
|
[Configurator]: https://www.inbucket.org/configurator/
|
||||||
|
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
|
||||||
|
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
|
||||||
|
[Docker Image]: https://www.inbucket.org/binaries/docker.html
|
||||||
|
[Elm]: https://elm-lang.org/
|
||||||
|
[From Source]: https://www.inbucket.org/installation/from-source.html
|
||||||
|
[Go]: https://golang.org/
|
||||||
|
[Go API docs]: https://pkg.go.dev/github.com/inbucket/inbucket/pkg/rest/client
|
||||||
|
[Homebrew]: http://brew.sh/
|
||||||
|
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
||||||
|
[Inbucket Website]: https://www.inbucket.org/
|
||||||
|
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
||||||
|
[Node.js]: https://nodejs.org/en/
|
||||||
|
|||||||
54
cmd/client/list.go
Normal file
54
cmd/client/list.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listCmd struct {
|
||||||
|
mailbox string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*listCmd) Name() string {
|
||||||
|
return "list"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*listCmd) Synopsis() string {
|
||||||
|
return "list contents of mailbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*listCmd) Usage() string {
|
||||||
|
return `list <mailbox>:
|
||||||
|
list message IDs in mailbox
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listCmd) SetFlags(f *flag.FlagSet) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *listCmd) Execute(
|
||||||
|
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
|
mailbox := f.Arg(0)
|
||||||
|
if mailbox == "" {
|
||||||
|
return usage("mailbox required")
|
||||||
|
}
|
||||||
|
// Setup rest client
|
||||||
|
c, err := client.New(baseURL())
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Couldn't build client", err)
|
||||||
|
}
|
||||||
|
// Get list
|
||||||
|
headers, err := c.ListMailbox(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return fatal("REST call failed", err)
|
||||||
|
}
|
||||||
|
for _, h := range headers {
|
||||||
|
fmt.Println(h.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subcommands.ExitSuccess
|
||||||
|
}
|
||||||
79
cmd/client/main.go
Normal file
79
cmd/client/main.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Package main implements a command line client for the Inbucket REST API
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
)
|
||||||
|
|
||||||
|
var host = flag.String("host", "localhost", "host/IP of Inbucket server")
|
||||||
|
var port = flag.Uint("port", 9000, "HTTP port of Inbucket server")
|
||||||
|
|
||||||
|
// Allow subcommands to accept regular expressions as flags
|
||||||
|
type regexFlag struct {
|
||||||
|
*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *regexFlag) Defined() bool {
|
||||||
|
return r.Regexp != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *regexFlag) Set(pattern string) error {
|
||||||
|
if pattern == "" {
|
||||||
|
r.Regexp = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Regexp = re
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *regexFlag) String() string {
|
||||||
|
if r.Regexp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.Regexp.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexFlag must implement flag.Value
|
||||||
|
var _ flag.Value = ®exFlag{}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Important top-level flags
|
||||||
|
subcommands.ImportantFlag("host")
|
||||||
|
subcommands.ImportantFlag("port")
|
||||||
|
// Setup standard helpers
|
||||||
|
subcommands.Register(subcommands.HelpCommand(), "")
|
||||||
|
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||||
|
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||||
|
// Setup my commands
|
||||||
|
subcommands.Register(&listCmd{}, "")
|
||||||
|
subcommands.Register(&matchCmd{}, "")
|
||||||
|
subcommands.Register(&mboxCmd{}, "")
|
||||||
|
// Parse and execute
|
||||||
|
flag.Parse()
|
||||||
|
ctx := context.Background()
|
||||||
|
os.Exit(int(subcommands.Execute(ctx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseURL() string {
|
||||||
|
return fmt.Sprintf("http://%s:%v", *host, *port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(msg string, err error) subcommands.ExitStatus {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage(msg string) subcommands.ExitStatus {
|
||||||
|
fmt.Fprintln(os.Stderr, msg)
|
||||||
|
return subcommands.ExitUsageError
|
||||||
|
}
|
||||||
164
cmd/client/match.go
Normal file
164
cmd/client/match.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type matchCmd struct {
|
||||||
|
mailbox string
|
||||||
|
output string
|
||||||
|
outFunc func(headers []*client.MessageHeader) error
|
||||||
|
delete bool
|
||||||
|
// match criteria
|
||||||
|
from regexFlag
|
||||||
|
subject regexFlag
|
||||||
|
to regexFlag
|
||||||
|
maxAge time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*matchCmd) Name() string {
|
||||||
|
return "match"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*matchCmd) Synopsis() string {
|
||||||
|
return "output messages matching criteria"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*matchCmd) Usage() string {
|
||||||
|
return `match [flags] <mailbox>:
|
||||||
|
output messages matching all specified criteria
|
||||||
|
exit status will be 1 if no matches were found, otherwise 0
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *matchCmd) SetFlags(f *flag.FlagSet) {
|
||||||
|
f.StringVar(&m.output, "output", "id", "output format: id, json, or mbox")
|
||||||
|
f.BoolVar(&m.delete, "delete", false, "delete matched messages after output")
|
||||||
|
f.Var(&m.from, "from", "From header matching regexp (address, not name)")
|
||||||
|
f.Var(&m.subject, "subject", "Subject header matching regexp")
|
||||||
|
f.Var(&m.to, "to", "To header matching regexp (must match 1+ to address)")
|
||||||
|
f.DurationVar(
|
||||||
|
&m.maxAge, "maxage", 0,
|
||||||
|
"Matches must have been received in this time frame (ex: \"10s\", \"5m\")")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *matchCmd) Execute(
|
||||||
|
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
|
mailbox := f.Arg(0)
|
||||||
|
if mailbox == "" {
|
||||||
|
return usage("mailbox required")
|
||||||
|
}
|
||||||
|
// Select output function
|
||||||
|
switch m.output {
|
||||||
|
case "id":
|
||||||
|
m.outFunc = outputID
|
||||||
|
case "json":
|
||||||
|
m.outFunc = outputJSON
|
||||||
|
case "mbox":
|
||||||
|
m.outFunc = outputMbox
|
||||||
|
default:
|
||||||
|
return usage("unknown output type: " + m.output)
|
||||||
|
}
|
||||||
|
// Setup REST client
|
||||||
|
c, err := client.New(baseURL())
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Couldn't build client", err)
|
||||||
|
}
|
||||||
|
// Get list
|
||||||
|
headers, err := c.ListMailbox(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return fatal("List REST call failed", err)
|
||||||
|
}
|
||||||
|
// Find matches
|
||||||
|
matches := make([]*client.MessageHeader, 0, len(headers))
|
||||||
|
for _, h := range headers {
|
||||||
|
if m.match(h) {
|
||||||
|
matches = append(matches, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return error status if no matches
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
// Output matches
|
||||||
|
err = m.outFunc(matches)
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Error", err)
|
||||||
|
}
|
||||||
|
if m.delete {
|
||||||
|
// Delete matches
|
||||||
|
for _, h := range matches {
|
||||||
|
err = h.Delete()
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Delete REST call failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subcommands.ExitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// match returns true if header matches all defined criteria
|
||||||
|
func (m *matchCmd) match(header *client.MessageHeader) bool {
|
||||||
|
if m.maxAge > 0 {
|
||||||
|
if time.Since(header.Date) > m.maxAge {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.subject.Defined() {
|
||||||
|
if !m.subject.MatchString(header.Subject) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.from.Defined() {
|
||||||
|
from := header.From
|
||||||
|
addr, err := mail.ParseAddress(from)
|
||||||
|
if err == nil {
|
||||||
|
// Parsed successfully
|
||||||
|
from = addr.Address
|
||||||
|
}
|
||||||
|
if !m.from.MatchString(from) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.to.Defined() {
|
||||||
|
match := false
|
||||||
|
for _, to := range header.To {
|
||||||
|
addr, err := mail.ParseAddress(to)
|
||||||
|
if err == nil {
|
||||||
|
// Parsed successfully
|
||||||
|
to = addr.Address
|
||||||
|
}
|
||||||
|
if m.to.MatchString(to) {
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputID(headers []*client.MessageHeader) error {
|
||||||
|
for _, h := range headers {
|
||||||
|
fmt.Println(h.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputJSON(headers []*client.MessageHeader) error {
|
||||||
|
jsonEncoder := json.NewEncoder(os.Stdout)
|
||||||
|
jsonEncoder.SetEscapeHTML(false)
|
||||||
|
jsonEncoder.SetIndent("", " ")
|
||||||
|
return jsonEncoder.Encode(headers)
|
||||||
|
}
|
||||||
82
cmd/client/mbox.go
Normal file
82
cmd/client/mbox.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mboxCmd struct {
|
||||||
|
mailbox string
|
||||||
|
delete bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*mboxCmd) Name() string {
|
||||||
|
return "mbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*mboxCmd) Synopsis() string {
|
||||||
|
return "output mailbox in mbox format"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*mboxCmd) Usage() string {
|
||||||
|
return `mbox [flags] <mailbox>:
|
||||||
|
output mailbox in mbox format
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
|
||||||
|
f.BoolVar(&m.delete, "delete", false, "delete messages after output")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mboxCmd) Execute(
|
||||||
|
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
|
mailbox := f.Arg(0)
|
||||||
|
if mailbox == "" {
|
||||||
|
return usage("mailbox required")
|
||||||
|
}
|
||||||
|
// Setup REST client
|
||||||
|
c, err := client.New(baseURL())
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Couldn't build client", err)
|
||||||
|
}
|
||||||
|
// Get list
|
||||||
|
headers, err := c.ListMailbox(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return fatal("List REST call failed", err)
|
||||||
|
}
|
||||||
|
err = outputMbox(headers)
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Error", err)
|
||||||
|
}
|
||||||
|
if m.delete {
|
||||||
|
// Delete matches
|
||||||
|
for _, h := range headers {
|
||||||
|
err = h.Delete()
|
||||||
|
if err != nil {
|
||||||
|
return fatal("Delete REST call failed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subcommands.ExitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputMbox renders messages in mbox format
|
||||||
|
// also used by match subcommand
|
||||||
|
func outputMbox(headers []*client.MessageHeader) error {
|
||||||
|
for _, h := range headers {
|
||||||
|
source, err := h.GetSource()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Get source REST failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("From %s\n", h.From)
|
||||||
|
// TODO Escape "From " in message bodies with >
|
||||||
|
source.WriteTo(os.Stdout)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
243
cmd/inbucket/main.go
Normal file
243
cmd/inbucket/main.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// main is the inbucket daemon launcher
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"expvar"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/pop3"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/inbucket/inbucket/pkg/webui"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// version contains the build version number, populated during linking.
|
||||||
|
version = "undefined"
|
||||||
|
|
||||||
|
// date contains the build date, populated during linking.
|
||||||
|
date = "undefined"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Server uptime for status page.
|
||||||
|
startTime := expvar.NewInt("startMillis")
|
||||||
|
startTime.Set(time.Now().UnixNano() / 1000000)
|
||||||
|
|
||||||
|
// Goroutine count for status page.
|
||||||
|
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||||
|
return runtime.NumGoroutine()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Register storage implementations.
|
||||||
|
storage.Constructors["file"] = file.New
|
||||||
|
storage.Constructors["memory"] = mem.New
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Command line flags.
|
||||||
|
help := flag.Bool("help", false, "Displays help on flags and env variables.")
|
||||||
|
pidfile := flag.String("pidfile", "", "Write our PID into the specified file.")
|
||||||
|
logfile := flag.String("logfile", "stderr", "Write out log into the specified file.")
|
||||||
|
logjson := flag.Bool("logjson", false, "Logs are written in JSON format.")
|
||||||
|
netdebug := flag.Bool("netdebug", false, "Dump SMTP & POP3 network traffic to stdout.")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage: inbucket [options]")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
flag.Parse()
|
||||||
|
if *help {
|
||||||
|
flag.Usage()
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
config.Usage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process configuration.
|
||||||
|
config.Version = version
|
||||||
|
config.BuildDate = date
|
||||||
|
conf, err := config.Process()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *netdebug {
|
||||||
|
conf.POP3.Debug = true
|
||||||
|
conf.SMTP.Debug = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger setup.
|
||||||
|
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Log error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
startupLog := log.With().Str("phase", "startup").Logger()
|
||||||
|
|
||||||
|
// Setup signal handler.
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
||||||
|
// Initialize logging.
|
||||||
|
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
||||||
|
Msg("Inbucket starting")
|
||||||
|
|
||||||
|
// Write pidfile if requested.
|
||||||
|
if *pidfile != "" {
|
||||||
|
pidf, err := os.Create(*pidfile)
|
||||||
|
if err != nil {
|
||||||
|
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to create pidfile")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(pidf, "%v\n", os.Getpid())
|
||||||
|
if err := pidf.Close(); err != nil {
|
||||||
|
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure internal services.
|
||||||
|
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||||
|
shutdownChan := make(chan bool)
|
||||||
|
store, err := storage.FromConfig(conf.Storage)
|
||||||
|
if err != nil {
|
||||||
|
removePIDFile(*pidfile)
|
||||||
|
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
|
||||||
|
}
|
||||||
|
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||||
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||||
|
|
||||||
|
// Start Retention scanner.
|
||||||
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||||
|
retentionScanner.Start()
|
||||||
|
|
||||||
|
// Configure routes and start HTTP server.
|
||||||
|
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||||
|
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
|
||||||
|
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
|
||||||
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
|
go web.Start(rootCtx)
|
||||||
|
|
||||||
|
// Start POP3 server.
|
||||||
|
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||||
|
go pop3Server.Start(rootCtx)
|
||||||
|
|
||||||
|
// Start SMTP server.
|
||||||
|
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||||
|
go smtpServer.Start(rootCtx)
|
||||||
|
|
||||||
|
// Loop forever waiting for signals or shutdown channel.
|
||||||
|
signalLoop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case sig := <-sigChan:
|
||||||
|
switch sig {
|
||||||
|
case syscall.SIGINT:
|
||||||
|
// Shutdown requested
|
||||||
|
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
|
||||||
|
Msg("Received SIGINT, shutting down")
|
||||||
|
close(shutdownChan)
|
||||||
|
case syscall.SIGTERM:
|
||||||
|
// Shutdown requested
|
||||||
|
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
|
||||||
|
Msg("Received SIGTERM, shutting down")
|
||||||
|
close(shutdownChan)
|
||||||
|
}
|
||||||
|
case <-shutdownChan:
|
||||||
|
rootCancel()
|
||||||
|
break signalLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for active connections to finish.
|
||||||
|
go timedExit(*pidfile)
|
||||||
|
smtpServer.Drain()
|
||||||
|
pop3Server.Drain()
|
||||||
|
retentionScanner.Join()
|
||||||
|
removePIDFile(*pidfile)
|
||||||
|
closeLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// openLog configures zerolog output, returns func to close logfile.
|
||||||
|
func openLog(level string, logfile string, json bool) (close func(), err error) {
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||||
|
case "info":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
|
case "warn":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||||
|
case "error":
|
||||||
|
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
|
||||||
|
}
|
||||||
|
close = func() {}
|
||||||
|
var w io.Writer
|
||||||
|
color := runtime.GOOS != "windows"
|
||||||
|
switch logfile {
|
||||||
|
case "stderr":
|
||||||
|
w = os.Stderr
|
||||||
|
case "stdout":
|
||||||
|
w = os.Stdout
|
||||||
|
default:
|
||||||
|
logf, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bw := bufio.NewWriter(logf)
|
||||||
|
w = bw
|
||||||
|
color = false
|
||||||
|
close = func() {
|
||||||
|
_ = bw.Flush()
|
||||||
|
_ = logf.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w = zerolog.SyncWriter(w)
|
||||||
|
if json {
|
||||||
|
log.Logger = log.Output(w)
|
||||||
|
return close, nil
|
||||||
|
}
|
||||||
|
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||||
|
Out: w,
|
||||||
|
NoColor: !color,
|
||||||
|
})
|
||||||
|
return close, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePIDFile removes the PID file if created.
|
||||||
|
func removePIDFile(pidfile string) {
|
||||||
|
if pidfile != "" {
|
||||||
|
if err := os.Remove(pidfile); err != nil {
|
||||||
|
log.Error().Str("phase", "shutdown").Err(err).Str("path", pidfile).
|
||||||
|
Msg("Failed to remove pidfile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// timedExit is called as a goroutine during shutdown, it will force an exit after 15 seconds.
|
||||||
|
func timedExit(pidfile string) {
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
removePIDFile(pidfile)
|
||||||
|
log.Error().Str("phase", "shutdown").Msg("Clean shutdown took too long, forcing exit")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
382
config/config.go
382
config/config.go
@@ -1,382 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"fmt"
|
|
||||||
"github.com/robfig/config"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SmtpConfig houses the SMTP server configuration - not using pointers
|
|
||||||
// so that I can pass around copies of the object safely.
|
|
||||||
type SmtpConfig struct {
|
|
||||||
Ip4address net.IP
|
|
||||||
Ip4port int
|
|
||||||
Domain string
|
|
||||||
DomainNoStore string
|
|
||||||
MaxRecipients int
|
|
||||||
MaxIdleSeconds int
|
|
||||||
MaxMessageBytes int
|
|
||||||
StoreMessages bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Pop3Config struct {
|
|
||||||
Ip4address net.IP
|
|
||||||
Ip4port int
|
|
||||||
Domain string
|
|
||||||
MaxIdleSeconds int
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebConfig struct {
|
|
||||||
Ip4address net.IP
|
|
||||||
Ip4port int
|
|
||||||
TemplateDir string
|
|
||||||
TemplateCache bool
|
|
||||||
PublicDir string
|
|
||||||
GreetingFile string
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataStoreConfig struct {
|
|
||||||
Path string
|
|
||||||
RetentionMinutes int
|
|
||||||
RetentionSleep int
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Global goconfig object
|
|
||||||
Config *config.Config
|
|
||||||
|
|
||||||
// Parsed specific configs
|
|
||||||
smtpConfig *SmtpConfig
|
|
||||||
pop3Config *Pop3Config
|
|
||||||
webConfig *WebConfig
|
|
||||||
dataStoreConfig *DataStoreConfig
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetSmtpConfig returns a copy of the SmtpConfig object
|
|
||||||
func GetSmtpConfig() SmtpConfig {
|
|
||||||
return *smtpConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPop3Config returns a copy of the Pop3Config object
|
|
||||||
func GetPop3Config() Pop3Config {
|
|
||||||
return *pop3Config
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetWebConfig returns a copy of the WebConfig object
|
|
||||||
func GetWebConfig() WebConfig {
|
|
||||||
return *webConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDataStoreConfig returns a copy of the DataStoreConfig object
|
|
||||||
func GetDataStoreConfig() DataStoreConfig {
|
|
||||||
return *dataStoreConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig loads the specified configuration file into inbucket.Config
|
|
||||||
// and performs validations on it.
|
|
||||||
func LoadConfig(filename string) error {
|
|
||||||
var err error
|
|
||||||
Config, err = config.ReadDefault(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := list.New()
|
|
||||||
|
|
||||||
// Validate sections
|
|
||||||
requireSection(messages, "logging")
|
|
||||||
requireSection(messages, "smtp")
|
|
||||||
requireSection(messages, "pop3")
|
|
||||||
requireSection(messages, "web")
|
|
||||||
requireSection(messages, "datastore")
|
|
||||||
if messages.Len() > 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
|
||||||
for e := messages.Front(); e != nil; e = e.Next() {
|
|
||||||
fmt.Fprintln(os.Stderr, " -", e.Value.(string))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Failed to validate configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate options
|
|
||||||
requireOption(messages, "logging", "level")
|
|
||||||
requireOption(messages, "smtp", "ip4.address")
|
|
||||||
requireOption(messages, "smtp", "ip4.port")
|
|
||||||
requireOption(messages, "smtp", "domain")
|
|
||||||
requireOption(messages, "smtp", "max.recipients")
|
|
||||||
requireOption(messages, "smtp", "max.idle.seconds")
|
|
||||||
requireOption(messages, "smtp", "max.message.bytes")
|
|
||||||
requireOption(messages, "smtp", "store.messages")
|
|
||||||
requireOption(messages, "pop3", "ip4.address")
|
|
||||||
requireOption(messages, "pop3", "ip4.port")
|
|
||||||
requireOption(messages, "pop3", "domain")
|
|
||||||
requireOption(messages, "pop3", "max.idle.seconds")
|
|
||||||
requireOption(messages, "web", "ip4.address")
|
|
||||||
requireOption(messages, "web", "ip4.port")
|
|
||||||
requireOption(messages, "web", "template.dir")
|
|
||||||
requireOption(messages, "web", "template.cache")
|
|
||||||
requireOption(messages, "web", "public.dir")
|
|
||||||
requireOption(messages, "datastore", "path")
|
|
||||||
requireOption(messages, "datastore", "retention.minutes")
|
|
||||||
requireOption(messages, "datastore", "retention.sleep.millis")
|
|
||||||
|
|
||||||
// Return error if validations failed
|
|
||||||
if messages.Len() > 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
|
||||||
for e := messages.Front(); e != nil; e = e.Next() {
|
|
||||||
fmt.Fprintln(os.Stderr, " -", e.Value.(string))
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Failed to validate configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = parseSmtpConfig(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = parsePop3Config(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = parseWebConfig(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = parseDataStoreConfig(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLoggingConfig trying to catch config errors early
|
|
||||||
func parseLoggingConfig() error {
|
|
||||||
section := "logging"
|
|
||||||
|
|
||||||
option := "level"
|
|
||||||
str, err := Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
switch strings.ToUpper(str) {
|
|
||||||
case "TRACE", "INFO", "WARN", "ERROR":
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("Invalid value provided for [%v]%v: '%v'", section, option, str)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSmtpConfig trying to catch config errors early
|
|
||||||
func parseSmtpConfig() error {
|
|
||||||
smtpConfig = new(SmtpConfig)
|
|
||||||
section := "smtp"
|
|
||||||
|
|
||||||
// Parse IP4 address only, error on IP6.
|
|
||||||
option := "ip4.address"
|
|
||||||
str, err := Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
addr := net.ParseIP(str)
|
|
||||||
if addr == nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
addr = addr.To4()
|
|
||||||
if addr == nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err)
|
|
||||||
}
|
|
||||||
smtpConfig.Ip4address = addr
|
|
||||||
|
|
||||||
option = "ip4.port"
|
|
||||||
smtpConfig.Ip4port, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "domain"
|
|
||||||
str, err = Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
smtpConfig.Domain = str
|
|
||||||
|
|
||||||
option = "domain.nostore"
|
|
||||||
if Config.HasOption(section, option) {
|
|
||||||
str, err = Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
smtpConfig.DomainNoStore = str
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "max.recipients"
|
|
||||||
smtpConfig.MaxRecipients, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "max.idle.seconds"
|
|
||||||
smtpConfig.MaxIdleSeconds, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "max.message.bytes"
|
|
||||||
smtpConfig.MaxMessageBytes, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "store.messages"
|
|
||||||
flag, err := Config.Bool(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
smtpConfig.StoreMessages = flag
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePop3Config trying to catch config errors early
|
|
||||||
func parsePop3Config() error {
|
|
||||||
pop3Config = new(Pop3Config)
|
|
||||||
section := "pop3"
|
|
||||||
|
|
||||||
// Parse IP4 address only, error on IP6.
|
|
||||||
option := "ip4.address"
|
|
||||||
str, err := Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
addr := net.ParseIP(str)
|
|
||||||
if addr == nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
addr = addr.To4()
|
|
||||||
if addr == nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err)
|
|
||||||
}
|
|
||||||
pop3Config.Ip4address = addr
|
|
||||||
|
|
||||||
option = "ip4.port"
|
|
||||||
pop3Config.Ip4port, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "domain"
|
|
||||||
str, err = Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
pop3Config.Domain = str
|
|
||||||
|
|
||||||
option = "max.idle.seconds"
|
|
||||||
pop3Config.MaxIdleSeconds, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseWebConfig trying to catch config errors early
|
|
||||||
func parseWebConfig() error {
|
|
||||||
webConfig = new(WebConfig)
|
|
||||||
section := "web"
|
|
||||||
|
|
||||||
// Parse IP4 address only, error on IP6.
|
|
||||||
option := "ip4.address"
|
|
||||||
str, err := Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
addr := net.ParseIP(str)
|
|
||||||
if addr == nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
addr = addr.To4()
|
|
||||||
if addr == nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err)
|
|
||||||
}
|
|
||||||
webConfig.Ip4address = addr
|
|
||||||
|
|
||||||
option = "ip4.port"
|
|
||||||
webConfig.Ip4port, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
option = "template.dir"
|
|
||||||
str, err = Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
webConfig.TemplateDir = str
|
|
||||||
|
|
||||||
option = "template.cache"
|
|
||||||
flag, err := Config.Bool(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
webConfig.TemplateCache = flag
|
|
||||||
|
|
||||||
option = "public.dir"
|
|
||||||
str, err = Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
webConfig.PublicDir = str
|
|
||||||
|
|
||||||
option = "greeting.file"
|
|
||||||
str, err = Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
webConfig.GreetingFile = str
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDataStoreConfig trying to catch config errors early
|
|
||||||
func parseDataStoreConfig() error {
|
|
||||||
dataStoreConfig = new(DataStoreConfig)
|
|
||||||
section := "datastore"
|
|
||||||
|
|
||||||
option := "path"
|
|
||||||
str, err := Config.String(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
dataStoreConfig.Path = str
|
|
||||||
|
|
||||||
option = "retention.minutes"
|
|
||||||
dataStoreConfig.RetentionMinutes, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
option = "retention.sleep.millis"
|
|
||||||
dataStoreConfig.RetentionSleep, err = Config.Int(section, option)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireSection checks that a [section] is defined in the configuration file,
|
|
||||||
// appending a message if not.
|
|
||||||
func requireSection(messages *list.List, section string) {
|
|
||||||
if !Config.HasSection(section) {
|
|
||||||
messages.PushBack(fmt.Sprintf("Config section [%v] is required", section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireOption checks that 'option' is defined in [section] of the config file,
|
|
||||||
// appending a message if not.
|
|
||||||
func requireOption(messages *list.List, section string, option string) {
|
|
||||||
if !Config.HasOption(section, option) {
|
|
||||||
messages.PushBack(fmt.Sprintf("Config option '%v' is required in section [%v]", option, section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
461
doc/config.md
Normal file
461
doc/config.md
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
# Inbucket Configuration
|
||||||
|
|
||||||
|
Inbucket is configured via environment variables. Most options have a
|
||||||
|
reasonable default, but it is likely you will need to change some to suite your
|
||||||
|
desired use cases.
|
||||||
|
|
||||||
|
Running `inbucket -help` will yield a condensed summary of the environment
|
||||||
|
variables it supports:
|
||||||
|
|
||||||
|
KEY DEFAULT DESCRIPTION
|
||||||
|
INBUCKET_LOGLEVEL info debug, info, warn, or error
|
||||||
|
INBUCKET_MAILBOXNAMING local Use local or full addressing
|
||||||
|
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
|
||||||
|
INBUCKET_SMTP_DOMAIN inbucket HELO domain
|
||||||
|
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
|
||||||
|
INBUCKET_SMTP_MAXMESSAGEBYTES 10240000 Maximum message size
|
||||||
|
INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default?
|
||||||
|
INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for
|
||||||
|
INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for
|
||||||
|
INBUCKET_SMTP_DEFAULTSTORE true Store all mail by default?
|
||||||
|
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
||||||
|
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
||||||
|
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
|
||||||
|
INBUCKET_SMTP_TLSENABLED false Enable STARTTLS option
|
||||||
|
INBUCKET_SMTP_TLSPRIVKEY cert.key X509 Private Key file for TLS Support
|
||||||
|
INBUCKET_SMTP_TLSCERT cert.crt X509 Public Certificate file for TLS Support
|
||||||
|
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
|
||||||
|
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||||
|
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||||
|
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||||
|
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
|
||||||
|
INBUCKET_WEB_UIDIR ui/dist User interface dir
|
||||||
|
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||||
|
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||||
|
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||||
|
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
|
||||||
|
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
|
||||||
|
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
|
||||||
|
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
|
||||||
|
INBUCKET_STORAGE_RETENTIONSLEEP 50ms Duration to sleep between mailboxes
|
||||||
|
INBUCKET_STORAGE_MAILBOXMSGCAP 500 Maximum messages per mailbox
|
||||||
|
|
||||||
|
The following documentation will describe each of these in more detail.
|
||||||
|
|
||||||
|
|
||||||
|
## Global
|
||||||
|
|
||||||
|
### Log Level
|
||||||
|
|
||||||
|
`INBUCKET_LOGLEVEL`
|
||||||
|
|
||||||
|
This setting controls the verbosity of log output. A small desktop installation
|
||||||
|
should probably select `info`, but a busy shared installation would be better
|
||||||
|
off with `warn` or `error`.
|
||||||
|
|
||||||
|
- Default: `info`
|
||||||
|
- Values: one of `debug`, `info`, `warn`, or `error`
|
||||||
|
|
||||||
|
### Mailbox Naming
|
||||||
|
|
||||||
|
`INBUCKET_MAILBOXNAMING`
|
||||||
|
|
||||||
|
The mailbox naming setting determines the name of a mailbox for an incoming
|
||||||
|
message, and thus where it must be retrieved from later.
|
||||||
|
|
||||||
|
#### `local` ensures the domain is removed, such that:
|
||||||
|
|
||||||
|
- `james@inbucket.org` is stored in `james`
|
||||||
|
- `james+spam@inbucket.org` is stored in `james`
|
||||||
|
|
||||||
|
#### `full` retains the domain as part of the name, such that:
|
||||||
|
|
||||||
|
- `james@inbucket.org` is stored in `james@inbucket.org`
|
||||||
|
- `james+spam@inbucket.org` is stored in `james@inbucket.org`
|
||||||
|
|
||||||
|
Prior to the addition of the mailbox naming setting, Inbucket always operated in
|
||||||
|
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
||||||
|
incorporated into the mailbox name.
|
||||||
|
|
||||||
|
#### `domain` ensures the local-part is removed, such that:
|
||||||
|
|
||||||
|
- `james@inbucket.org` is stored in `inbucket.org`
|
||||||
|
- `matt@inbucket.org` is stored in `inbucket.org`
|
||||||
|
- `matt@noinbucket.com` is stored in `notinbucket.com`
|
||||||
|
|
||||||
|
- Default: `local`
|
||||||
|
- Values: one of `local` or `full` or `domain`
|
||||||
|
|
||||||
|
|
||||||
|
## SMTP
|
||||||
|
|
||||||
|
### Address and Port
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_ADDR`
|
||||||
|
|
||||||
|
The IPv4 address and TCP port number the SMTP server should listen on, separated
|
||||||
|
by a colon. Some operating systems may prevent Inbucket from listening on port
|
||||||
|
25 without escalated privileges. Using an IP address of 0.0.0.0 will cause
|
||||||
|
Inbucket to listen on all available network interfaces.
|
||||||
|
|
||||||
|
- Default: `0.0.0.0:2500`
|
||||||
|
|
||||||
|
### Greeting Domain
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DOMAIN`
|
||||||
|
|
||||||
|
The domain used in the SMTP greeting:
|
||||||
|
|
||||||
|
220 domain Inbucket SMTP ready
|
||||||
|
|
||||||
|
Most SMTP clients appear to ignore this value.
|
||||||
|
|
||||||
|
- Default: `inbucket`
|
||||||
|
|
||||||
|
### Maximum Recipients
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_MAXRECIPIENTS`
|
||||||
|
|
||||||
|
Maximum number of recipients allowed (SMTP `RCPT TO` phase). If you are testing
|
||||||
|
a mailing list server, you may need to increase this value. For comparison, the
|
||||||
|
Postfix SMTP server uses a default of 1000, it would be unwise to exceed this.
|
||||||
|
|
||||||
|
- Default: `200`
|
||||||
|
|
||||||
|
### Maximum Message Size
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_MAXMESSAGEBYTES`
|
||||||
|
|
||||||
|
Maximum allowable size of a message (including headers) in bytes. Messages
|
||||||
|
exceeding this size will be rejected during the SMTP `DATA` phase.
|
||||||
|
|
||||||
|
- Default: `10240000` (10MB)
|
||||||
|
|
||||||
|
### Default Recipient Accept Policy
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DEFAULTACCEPT`
|
||||||
|
|
||||||
|
If true, Inbucket will accept mail to any domain unless present in the reject
|
||||||
|
domains list. If false, recipients will be rejected unless their domain is
|
||||||
|
present in the accept domains list.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Accepted Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_ACCEPTDOMAINS`
|
||||||
|
|
||||||
|
List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
|
||||||
|
has no effect when true.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `localhost,mysite.org`
|
||||||
|
|
||||||
|
### Rejected Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_REJECTDOMAINS`
|
||||||
|
|
||||||
|
List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
|
||||||
|
has no effect when false.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `reject.com,gmail.com`
|
||||||
|
|
||||||
|
### Default Recipient Store Policy
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DEFAULTSTORE`
|
||||||
|
|
||||||
|
If true, Inbucket will store mail sent to any domain unless present in the
|
||||||
|
discard domains list. If false, messages will be discarded unless their domain
|
||||||
|
is present in the store domains list.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Stored Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_STOREDOMAINS`
|
||||||
|
|
||||||
|
List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
|
||||||
|
has no effect when true.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `localhost,mysite.org`
|
||||||
|
|
||||||
|
### Discarded Recipient Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_DISCARDDOMAINS`
|
||||||
|
|
||||||
|
Mail sent to these domains will not be stored by Inbucket. This is helpful if
|
||||||
|
you are load or soak testing a service, and do not plan to inspect the resulting
|
||||||
|
emails. Messages sent to a domain other than this will be stored normally.
|
||||||
|
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of domains
|
||||||
|
- Example: `recycle.com,loadtest.org`
|
||||||
|
|
||||||
|
### Network Idle Timeout
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_TIMEOUT`
|
||||||
|
|
||||||
|
Delay before closing an idle SMTP connection. The SMTP RFC recommends 300
|
||||||
|
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
|
||||||
|
to the public internet.
|
||||||
|
|
||||||
|
- Default: `300s`
|
||||||
|
- Values: Duration ending in `s` for seconds, `m` for minutes
|
||||||
|
|
||||||
|
### TLS Support Availability
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_TLSENABLED`
|
||||||
|
|
||||||
|
Enable the STARTTLS option for opportunistic TLS support
|
||||||
|
|
||||||
|
- Default: `false`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### TLS Private Key File
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_TLSPRIVKEY`
|
||||||
|
|
||||||
|
Specify the x509 Private key file to be used for TLS negotiation.
|
||||||
|
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||||
|
|
||||||
|
- Default: `cert.key`
|
||||||
|
- Values: filename or path to private key
|
||||||
|
- Example: `server.privkey`
|
||||||
|
|
||||||
|
### TLS Public Certificate File
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_TLSCERT`
|
||||||
|
|
||||||
|
Specify the x509 Certificate file to be used for TLS negotiation.
|
||||||
|
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||||
|
|
||||||
|
- Default: `cert.crt`
|
||||||
|
- Values: filename or path to the certificate key
|
||||||
|
- Example: `server.crt`
|
||||||
|
|
||||||
|
## POP3
|
||||||
|
|
||||||
|
### Address and Port
|
||||||
|
|
||||||
|
`INBUCKET_POP3_ADDR`
|
||||||
|
|
||||||
|
The IPv4 address and TCP port number the POP3 server should listen on, separated
|
||||||
|
by a colon. Some operating systems may prevent Inbucket from listening on port
|
||||||
|
110 without escalated privileges. Using an IP address of 0.0.0.0 will cause
|
||||||
|
Inbucket to listen on all available network interfaces.
|
||||||
|
|
||||||
|
- Default: `0.0.0.0:1100`
|
||||||
|
|
||||||
|
### Greeting Domain
|
||||||
|
|
||||||
|
`INBUCKET_POP3_DOMAIN`
|
||||||
|
|
||||||
|
The domain used in the POP3 greeting:
|
||||||
|
|
||||||
|
+OK Inbucket POP3 server ready <26641.1522000423@domain>
|
||||||
|
|
||||||
|
Most POP3 clients appear to ignore this value.
|
||||||
|
|
||||||
|
- Default: `inbucket`
|
||||||
|
|
||||||
|
### Network Idle Timeout
|
||||||
|
|
||||||
|
`INBUCKET_POP3_TIMEOUT`
|
||||||
|
|
||||||
|
Delay before closing an idle POP3 connection. The POP3 RFC recommends 600
|
||||||
|
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
|
||||||
|
to the public internet.
|
||||||
|
|
||||||
|
- Default: `600s`
|
||||||
|
- Values: Duration ending in `s` for seconds, `m` for minutes
|
||||||
|
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
### Address and Port
|
||||||
|
|
||||||
|
`INBUCKET_WEB_ADDR`
|
||||||
|
|
||||||
|
The IPv4 address and TCP port number the HTTP server should listen on, separated
|
||||||
|
by a colon. Some operating systems may prevent Inbucket from listening on port
|
||||||
|
80 without escalated privileges. Using an IP address of 0.0.0.0 will cause
|
||||||
|
Inbucket to listen on all available network interfaces.
|
||||||
|
|
||||||
|
- Default: `0.0.0.0:9000`
|
||||||
|
|
||||||
|
### Base Path
|
||||||
|
|
||||||
|
`INBUCKET_WEB_BASEPATH`
|
||||||
|
|
||||||
|
Base path prefix for UI and API URLs. This option is used when you wish to
|
||||||
|
root all Inbucket URLs to a specific path when placing it behind a
|
||||||
|
reverse-proxy.
|
||||||
|
|
||||||
|
For example, setting the base path to `prefix` will move:
|
||||||
|
- the Inbucket status page from `/status` to `/prefix/status`,
|
||||||
|
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
|
||||||
|
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
|
||||||
|
|
||||||
|
*Note:* This setting will not work correctly when running Inbucket via the npm
|
||||||
|
development server.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
|
||||||
|
### UI Directory
|
||||||
|
|
||||||
|
`INBUCKET_WEB_UIDIR`
|
||||||
|
|
||||||
|
This directory contains the templates and static assets for the web user
|
||||||
|
interface. You will need to change this if the current working directory
|
||||||
|
doesn't contain the `ui` directory at startup.
|
||||||
|
|
||||||
|
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||||
|
static assets from the `static` sub-directory.
|
||||||
|
|
||||||
|
- Default: `ui/dist`
|
||||||
|
- Values: Operating system specific path syntax
|
||||||
|
|
||||||
|
### Greeting HTML File
|
||||||
|
|
||||||
|
`INBUCKET_WEB_GREETINGFILE`
|
||||||
|
|
||||||
|
The content of the greeting file will be injected into the front page of
|
||||||
|
Inbucket. It can be used to instruct users on how to send mail into your
|
||||||
|
Inbucket installation, as well as link to REST documentation, etc.
|
||||||
|
|
||||||
|
- Default: `ui/greeting.html`
|
||||||
|
|
||||||
|
### Monitor Visible
|
||||||
|
|
||||||
|
`INBUCKET_WEB_MONITORVISIBLE`
|
||||||
|
|
||||||
|
If true, the Monitor tab will be available, allowing users to observe all
|
||||||
|
messages received by Inbucket as they arrive. Disabling the monitor facilitates
|
||||||
|
security through obscurity.
|
||||||
|
|
||||||
|
This setting has no impact on the availability of the underlying WebSocket,
|
||||||
|
which may be used by other parts of the Inbucket interface or continuous
|
||||||
|
integration tests.
|
||||||
|
|
||||||
|
- Default: `true`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
### Monitor History
|
||||||
|
|
||||||
|
`INBUCKET_WEB_MONITORHISTORY`
|
||||||
|
|
||||||
|
The number of messages to remember on the *server* for new Monitor clients.
|
||||||
|
Does not impact the amount of *new* messages displayed by the Monitor.
|
||||||
|
Increasing this has no appreciable impact on memory use, but may slow down the
|
||||||
|
Monitor user interface.
|
||||||
|
|
||||||
|
This setting has the same effect on the amount of messages available via
|
||||||
|
WebSocket.
|
||||||
|
|
||||||
|
Setting to 0 will disable the monitor, but will probably break new mail
|
||||||
|
notifications in the web interface when I finally get around to implementing
|
||||||
|
them.
|
||||||
|
|
||||||
|
- Default: `30`
|
||||||
|
- Values: Integer greater than or equal to 0
|
||||||
|
|
||||||
|
### Performance Profiling & Debug Tools
|
||||||
|
|
||||||
|
`INBUCKET_WEB_PPROF`
|
||||||
|
|
||||||
|
If true, Go's pprof package will be installed to the `/debug/pprof` URI. This
|
||||||
|
exposes detailed memory and CPU performance data for debugging Inbucket. If you
|
||||||
|
enable this option, please make sure it is not exposed to the public internet,
|
||||||
|
as its use can significantly impact performance.
|
||||||
|
|
||||||
|
For example usage, see https://golang.org/pkg/net/http/pprof/
|
||||||
|
|
||||||
|
- Default: `false`
|
||||||
|
- Values: `true` or `false`
|
||||||
|
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### Type
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_TYPE`
|
||||||
|
|
||||||
|
Selects the storage implementation to use. Currently Inbucket supports two:
|
||||||
|
|
||||||
|
- `file`: stores messages as individual files in a nested directory structure
|
||||||
|
based on the hash of the mailbox name. Each mailbox also includes an index
|
||||||
|
file to speed up enumeration of the mailbox contents.
|
||||||
|
- `memory`: stores messages in RAM, they will be lost if Inbucket is restarted,
|
||||||
|
or crashes, etc.
|
||||||
|
|
||||||
|
File storage is recommended for larger/shared installations. Memory is better
|
||||||
|
suited to desktop or continuous integration test use cases.
|
||||||
|
|
||||||
|
- Default: `memory`
|
||||||
|
- Values: `file` or `memory`
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_PARAMS`
|
||||||
|
|
||||||
|
Parameters specific to the storage type selected. Formatted as a comma
|
||||||
|
separated list of key:value pairs.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Examples: `maxkb:10240` or `path:/tmp/inbucket`
|
||||||
|
|
||||||
|
#### `file` type parameters
|
||||||
|
|
||||||
|
- `path`: Operating system specific path to the directory where mail should be
|
||||||
|
stored.
|
||||||
|
|
||||||
|
#### `memory` type parameters
|
||||||
|
|
||||||
|
- `maxkb`: Maximum size of the mail store in kilobytes. The oldest messages in
|
||||||
|
the store will be deleted to enforce the limit. In-memory storage has some
|
||||||
|
overhead, for now it is recommended to set this to half the total amount of
|
||||||
|
memory you are willing to allocate to Inbucket.
|
||||||
|
|
||||||
|
### Retention Period
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_RETENTIONPERIOD`
|
||||||
|
|
||||||
|
If set, Inbucket will scan the contents of its mail store once per minute,
|
||||||
|
removing messages older than this. This will be enforced regardless of the type
|
||||||
|
of storage configured.
|
||||||
|
|
||||||
|
- Default: `24h`
|
||||||
|
- Values: Duration ending in `m` for minutes, `h` for hours. Should be
|
||||||
|
significantly longer than one minute, or `0` to disable.
|
||||||
|
|
||||||
|
### Retention Sleep
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_RETENTIONSLEEP`
|
||||||
|
|
||||||
|
Duration to sleep between scanning each mailbox for expired messages.
|
||||||
|
Increasing this number will reduce disk thrashing, but extend the length of time
|
||||||
|
required to complete a scan of the entire mail store.
|
||||||
|
|
||||||
|
This delay is still enforced for memory stores, but could be reduced from the
|
||||||
|
default. Setting to `0` may degrade performance of HTTP/SMTP/POP3 services.
|
||||||
|
|
||||||
|
- Default: `50ms`
|
||||||
|
- Values: Duration ending in `ms` for milliseconds, `s` for seconds
|
||||||
|
|
||||||
|
### Per Mailbox Message Cap
|
||||||
|
|
||||||
|
`INBUCKET_STORAGE_MAILBOXMSGCAP`
|
||||||
|
|
||||||
|
Maximum messages allowed in a single mailbox, exceeding this will cause older
|
||||||
|
messages to be deleted from the mailbox.
|
||||||
|
|
||||||
|
- Default: `500`
|
||||||
|
- Values: Positive integer, or `0` to disable
|
||||||
35
etc/dev-start.sh
Executable file
35
etc/dev-start.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# dev-start.sh
|
||||||
|
# description: Developer friendly Inbucket configuration
|
||||||
|
|
||||||
|
export INBUCKET_LOGLEVEL="debug"
|
||||||
|
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
||||||
|
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
||||||
|
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
||||||
|
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
||||||
|
#export INBUCKET_SMTP_DEFAULTSTORE="false"
|
||||||
|
export INBUCKET_SMTP_STOREDOMAINS="important.local"
|
||||||
|
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||||
|
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||||
|
export INBUCKET_WEB_UIDIR="ui/dist"
|
||||||
|
#export INBUCKET_WEB_MONITORVISIBLE="false"
|
||||||
|
#export INBUCKET_WEB_BASEPATH="prefix"
|
||||||
|
export INBUCKET_STORAGE_TYPE="file"
|
||||||
|
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||||
|
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||||
|
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
|
||||||
|
|
||||||
|
if ! test -x ./inbucket; then
|
||||||
|
echo "$PWD/inbucket not found/executable!" >&2
|
||||||
|
echo "Run this script from the inbucket root directory after running make." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||||
|
if ! test -f "$index"; then
|
||||||
|
echo "$index does not exist!" >&2
|
||||||
|
echo "Run 'npm run build' from the 'ui' directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec ./inbucket $*
|
||||||
102
etc/devel.conf
102
etc/devel.conf
@@ -1,102 +0,0 @@
|
|||||||
# devel.conf
|
|
||||||
# Sample development configuration
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used by directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=.
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=TRACE
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=30
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=20480000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=integral
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=false
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can
|
|
||||||
# be moved out of installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s/themes/greeting.html
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/tmp/inbucket
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=0
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
17
etc/docker/defaults/greeting.html
Normal file
17
etc/docker/defaults/greeting.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<h1>Welcome to Inbucket</h1>
|
||||||
|
|
||||||
|
<p>Inbucket is an email testing service; it will accept email for any email
|
||||||
|
address and make it available to view without a password.</p>
|
||||||
|
|
||||||
|
<p>To view messages for a particular address, enter the username portion
|
||||||
|
of the address into the box on the upper right and click <em>View</em>.</p>
|
||||||
|
|
||||||
|
<p>This instance of Inbucket is running inside of a <a
|
||||||
|
href="https://www.docker.com/" target="_blank">Docker</a> container. It is
|
||||||
|
configured to retain messages for a maximum of 3 days, and will enforce a limit
|
||||||
|
of 300 messages per mailbox - the oldest messages will be deleted to stay under
|
||||||
|
that limit.</p>
|
||||||
|
|
||||||
|
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
|
||||||
|
domain will be accepted but not written to disk. Use this domain for load or
|
||||||
|
soak testing your application.</p>
|
||||||
24
etc/docker/defaults/start-inbucket.sh
Executable file
24
etc/docker/defaults/start-inbucket.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# start-inbucket.sh
|
||||||
|
# description: start inbucket (runs within a docker container)
|
||||||
|
|
||||||
|
INBUCKET_HOME="/opt/inbucket"
|
||||||
|
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
||||||
|
CONF_TARGET="/config"
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
install_default_config() {
|
||||||
|
local file="$1"
|
||||||
|
local source="$CONF_SOURCE/$file"
|
||||||
|
local target="$CONF_TARGET/$file"
|
||||||
|
|
||||||
|
if [ ! -e "$target" ]; then
|
||||||
|
echo "Installing default $file to $CONF_TARGET"
|
||||||
|
install "$source" "$target"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_default_config "greeting.html"
|
||||||
|
|
||||||
|
exec "$INBUCKET_HOME/bin/inbucket" $*
|
||||||
62
etc/docker/docker-run.sh
Executable file
62
etc/docker/docker-run.sh
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# docker-run.sh
|
||||||
|
# description: Launch Inbucket's docker image
|
||||||
|
|
||||||
|
# Docker Image Tag
|
||||||
|
IMAGE="inbucket/inbucket"
|
||||||
|
|
||||||
|
# Ports exposed on host:
|
||||||
|
PORT_HTTP=9000
|
||||||
|
PORT_SMTP=2500
|
||||||
|
PORT_POP3=1100
|
||||||
|
|
||||||
|
# Volumes exposed on host:
|
||||||
|
VOL_CONFIG="/tmp/inbucket/config"
|
||||||
|
VOL_DATA="/tmp/inbucket/storage"
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local run_opts=""
|
||||||
|
|
||||||
|
for arg in $*; do
|
||||||
|
case "$arg" in
|
||||||
|
-h)
|
||||||
|
usage
|
||||||
|
exit
|
||||||
|
;;
|
||||||
|
-r)
|
||||||
|
reset
|
||||||
|
;;
|
||||||
|
-d)
|
||||||
|
run_opts="$run_opts -d"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
docker run $run_opts \
|
||||||
|
-p $PORT_HTTP:9000 \
|
||||||
|
-p $PORT_SMTP:2500 \
|
||||||
|
-p $PORT_POP3:1100 \
|
||||||
|
-v "$VOL_CONFIG:/config" \
|
||||||
|
-v "$VOL_DATA:/storage" \
|
||||||
|
"$IMAGE"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "$0 [options]" 2>&1
|
||||||
|
echo " -d detach - detach and print container ID" 2>&1
|
||||||
|
echo " -r reset - purge config and data before startup" 2>&1
|
||||||
|
echo " -h help - print this message" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
/bin/rm -rf "$VOL_CONFIG"
|
||||||
|
/bin/rm -rf "$VOL_DATA"
|
||||||
|
}
|
||||||
|
|
||||||
|
main $*
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# inbucket.conf
|
|
||||||
# Sample inbucket configuration
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used by directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=.
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
#domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=integral
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can
|
|
||||||
# be moved out of installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s/themes/greeting.html
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/tmp/inbucket
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=240
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
33
etc/linux/inbucket.service
Normal file
33
etc/linux/inbucket.service
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Inbucket Disposable Email Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=daemon
|
||||||
|
Group=daemon
|
||||||
|
PermissionsStartOnly=true
|
||||||
|
|
||||||
|
Environment=INBUCKET_LOGLEVEL=warn
|
||||||
|
Environment=INBUCKET_SMTP_ADDR=0.0.0.0:2500
|
||||||
|
Environment=INBUCKET_POP3_ADDR=0.0.0.0:1100
|
||||||
|
Environment=INBUCKET_WEB_ADDR=0.0.0.0:9000
|
||||||
|
Environment=INBUCKET_WEB_UIDIR=/usr/local/share/inbucket/ui
|
||||||
|
Environment=INBUCKET_WEB_GREETINGFILE=/etc/inbucket/greeting.html
|
||||||
|
Environment=INBUCKET_STORAGE_TYPE=file
|
||||||
|
Environment=INBUCKET_STORAGE_PARAMS=path:/var/local/inbucket
|
||||||
|
|
||||||
|
# Uncomment line below to use low numbered ports
|
||||||
|
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbucket
|
||||||
|
|
||||||
|
ExecStartPre=/bin/mkdir -p /var/local/inbucket
|
||||||
|
ExecStartPre=/bin/chown daemon:daemon /var/local/inbucket
|
||||||
|
|
||||||
|
ExecStart=/usr/local/bin/inbucket
|
||||||
|
|
||||||
|
# Give SMTP connections time to drain
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=mixed
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1 +0,0 @@
|
|||||||
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
curl -i -H "Accept: application/json" --noproxy localhost -X DELETE http://localhost:9000/mailbox/$1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1/$2
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
curl -i -H "Accept: application/json" --noproxy localhost -X DELETE http://localhost:9000/mailbox/$1/$2
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1/$2/source
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Please see the RedHat installation guide on our website:
|
|
||||||
|
|
||||||
http://jhillyerd.github.com/inbucket/installation/redhat.html
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Inbucket reverse proxy, Apache will forward requests from port 80
|
|
||||||
# to Inbucket's built in web server on port 9000
|
|
||||||
#
|
|
||||||
# Replace SERVERFQDN with your servers fully qualified domain name
|
|
||||||
<VirtualHost *:80>
|
|
||||||
ServerName SERVERFQDN
|
|
||||||
ProxyRequests off
|
|
||||||
|
|
||||||
<Proxy *>
|
|
||||||
Order allow,deny
|
|
||||||
Allow from all
|
|
||||||
</Proxy>
|
|
||||||
|
|
||||||
RewriteRule ^/$ http://SERVERFQDN:9000
|
|
||||||
ProxyPass / http://SERVERFQDN:9000/
|
|
||||||
ProxyPassReverse / http://SERVERFQDN:9000/
|
|
||||||
</VirtualHost>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
#
|
|
||||||
# inbucket Inbucket email testing service
|
|
||||||
#
|
|
||||||
# chkconfig: 2345 80 30
|
|
||||||
# description: Inbucket is a disposable email service for testing email
|
|
||||||
# functionality of other applications.
|
|
||||||
# processname: inbucket
|
|
||||||
# pidfile: /var/run/inbucket/inbucket.pid
|
|
||||||
|
|
||||||
### BEGIN INIT INFO
|
|
||||||
# Provides: Inbucket service
|
|
||||||
# Required-Start: $local_fs $network $remote_fs
|
|
||||||
# Required-Stop: $local_fs $network $remote_fs
|
|
||||||
# Default-Start: 2 3 4 5
|
|
||||||
# Default-Stop: 0 1 6
|
|
||||||
# Short-Description: start and stop inbucket
|
|
||||||
# Description: Inbucket is a disposable email service for testing email
|
|
||||||
# functionality of other applications.
|
|
||||||
# moves mail from one machine to another.
|
|
||||||
### END INIT INFO
|
|
||||||
|
|
||||||
# Source function library.
|
|
||||||
. /etc/rc.d/init.d/functions
|
|
||||||
|
|
||||||
# Source networking configuration.
|
|
||||||
. /etc/sysconfig/network
|
|
||||||
|
|
||||||
RETVAL=0
|
|
||||||
program=/opt/inbucket/inbucket
|
|
||||||
prog=${program##*/}
|
|
||||||
config=/etc/opt/inbucket.conf
|
|
||||||
runas=inbucket
|
|
||||||
|
|
||||||
lockfile=/var/lock/subsys/$prog
|
|
||||||
pidfile=/var/run/$prog/$prog.pid
|
|
||||||
logfile=/var/log/$prog.log
|
|
||||||
|
|
||||||
conf_check() {
|
|
||||||
[ -x $program ] || exit 5
|
|
||||||
[ -f $config ] || exit 6
|
|
||||||
}
|
|
||||||
|
|
||||||
perms_check() {
|
|
||||||
mkdir -p /var/run/$prog
|
|
||||||
chown $runas: /var/run/$prog
|
|
||||||
touch $logfile
|
|
||||||
chown $runas: $logfile
|
|
||||||
# Allow bind to ports under 1024
|
|
||||||
setcap 'cap_net_bind_service=+ep' $program
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
[ "$EUID" != "0" ] && exit 4
|
|
||||||
# Check that networking is up.
|
|
||||||
[ ${NETWORKING} = "no" ] && exit 1
|
|
||||||
# Check config sanity
|
|
||||||
conf_check
|
|
||||||
perms_check
|
|
||||||
# Start daemon
|
|
||||||
echo -n $"Starting $prog: "
|
|
||||||
daemon --user $runas --pidfile $pidfile $program \
|
|
||||||
-pidfile $pidfile -logfile $logfile $config \&
|
|
||||||
RETVAL=$?
|
|
||||||
[ $RETVAL -eq 0 ] && touch $lockfile
|
|
||||||
echo
|
|
||||||
return $RETVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
[ "$EUID" != "0" ] && exit 4
|
|
||||||
conf_check
|
|
||||||
# Stop daemon
|
|
||||||
echo -n $"Shutting down $prog: "
|
|
||||||
killproc -p "$pidfile" -d 15 "$program"
|
|
||||||
RETVAL=$?
|
|
||||||
[ $RETVAL -eq 0 ] && rm -f $lockfile $pidfile
|
|
||||||
echo
|
|
||||||
return $RETVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
reload() {
|
|
||||||
[ "$EUID" != "0" ] && exit 4
|
|
||||||
echo -n $"Reloading $prog: "
|
|
||||||
killproc -p "$pidfile" "$program" -HUP
|
|
||||||
RETVAL=$?
|
|
||||||
echo
|
|
||||||
return $RETVAL
|
|
||||||
}
|
|
||||||
|
|
||||||
# See how we were called.
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
[ -e $lockfile ] && exit 0
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
[ -e $lockfile ] || exit 0
|
|
||||||
stop
|
|
||||||
;;
|
|
||||||
reload)
|
|
||||||
[ -e $lockfile ] || exit 0
|
|
||||||
reload
|
|
||||||
;;
|
|
||||||
restart|force-reload)
|
|
||||||
stop
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
status -p $pidfile -l $(basename $lockfile) $prog
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo $"Usage: $0 {start|stop|restart|status}"
|
|
||||||
exit 2
|
|
||||||
esac
|
|
||||||
|
|
||||||
exit $?
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/var/log/inbucket.log {
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
create 0644 inbucket inbucket
|
|
||||||
postrotate
|
|
||||||
[ -e /etc/init.d/inbucket ] && /etc/init.d/inbucket reload >/dev/null 2>&1 || true
|
|
||||||
endscript
|
|
||||||
}
|
|
||||||
118
etc/rest-apiv1.sh
Executable file
118
etc/rest-apiv1.sh
Executable file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# rest-apiv1.sh
|
||||||
|
# description: Script to access Inbucket REST API version 1
|
||||||
|
|
||||||
|
API_HOST="localhost"
|
||||||
|
URL_ROOT="http://$API_HOST:9000/api/v1"
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
[ $TRACE ] && set -x
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 <command> [argument1 [argument2 [..]]]" >&2
|
||||||
|
echo >&2
|
||||||
|
echo "Options:" >&2
|
||||||
|
echo " -h - show this help" >&2
|
||||||
|
echo " -i - show HTTP headers" >&2
|
||||||
|
echo >&2
|
||||||
|
echo "Commands:" >&2
|
||||||
|
echo " list <mailbox> - list mailbox contents" >&2
|
||||||
|
echo " body <mailbox> <id> - print message body" >&2
|
||||||
|
echo " source <mailbox> <id> - print message source" >&2
|
||||||
|
echo " delete <mailbox> <id> - delete message" >&2
|
||||||
|
echo " purge <mailbox> - delete all messages in mailbox" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
arg_check() {
|
||||||
|
declare command="$1" expected="$2" received="$3"
|
||||||
|
if [ $expected != $received ]; then
|
||||||
|
echo "Error: Command '$command' requires $expected arguments, but received $received" >&2
|
||||||
|
echo >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
# Process options
|
||||||
|
local curl_opts=""
|
||||||
|
local pretty="true"
|
||||||
|
for arg in $*; do
|
||||||
|
if [[ $arg == -* ]]; then
|
||||||
|
case "$arg" in
|
||||||
|
-h)
|
||||||
|
usage
|
||||||
|
exit
|
||||||
|
;;
|
||||||
|
-i)
|
||||||
|
curl_opts="$curl_opts -i"
|
||||||
|
pretty=""
|
||||||
|
;;
|
||||||
|
**)
|
||||||
|
echo "Unknown option: $arg" >&2
|
||||||
|
echo
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Store command
|
||||||
|
declare command="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local url=""
|
||||||
|
local method="GET"
|
||||||
|
local is_json=""
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
body)
|
||||||
|
arg_check "$command" 2 $#
|
||||||
|
url="$URL_ROOT/mailbox/$1/$2"
|
||||||
|
is_json="true"
|
||||||
|
;;
|
||||||
|
delete)
|
||||||
|
arg_check "$command" 2 $#
|
||||||
|
method=DELETE
|
||||||
|
url="$URL_ROOT/mailbox/$1/$2"
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
arg_check "$command" 1 $#
|
||||||
|
url="$URL_ROOT/mailbox/$1"
|
||||||
|
is_json="true"
|
||||||
|
;;
|
||||||
|
purge)
|
||||||
|
arg_check "$command" 1 $#
|
||||||
|
method=DELETE
|
||||||
|
url="$URL_ROOT/mailbox/$1"
|
||||||
|
;;
|
||||||
|
source)
|
||||||
|
arg_check "$command" 2 $#
|
||||||
|
url="$URL_ROOT/mailbox/$1/$2/source"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command $command" >&2
|
||||||
|
echo >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Use jq to pretty-print if installed and we are expecting JSON output
|
||||||
|
if [ $pretty ] && [ $is_json ] && type -P jq >/dev/null; then
|
||||||
|
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url" | jq .
|
||||||
|
else
|
||||||
|
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
main $*
|
||||||
30
etc/swaks-tests/README.md
Normal file
30
etc/swaks-tests/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
swaks-tests
|
||||||
|
===========
|
||||||
|
|
||||||
|
[Swaks](http://www.jetmore.org/john/code/swaks/) - Swiss Army Knife for SMTP
|
||||||
|
|
||||||
|
Swaks gives us an easy way to generate mail to send into Inbucket. You will need to
|
||||||
|
install Swaks before you can use the provided scripts.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To deliver a batch of test email to the `swaks` mailbox, assuming Inbucket SMTP is listening
|
||||||
|
on localhost:2500:
|
||||||
|
|
||||||
|
./run-tests.sh
|
||||||
|
|
||||||
|
To deliver a batch of test email to the `james` mailbox:
|
||||||
|
|
||||||
|
./run-tests.sh james
|
||||||
|
|
||||||
|
You may also pass swaks options to deliver to a alternate host/port:
|
||||||
|
|
||||||
|
./run-tests --server inbucket.mydomain.com:25
|
||||||
|
|
||||||
|
To specify the mailbox with an alternate server, use `--to` with a local and host part:
|
||||||
|
|
||||||
|
./run-tests --server inbucket.mydomain.com:25 --to james@mydomain.com
|
||||||
|
|
||||||
|
## To Do
|
||||||
|
|
||||||
|
Replace Swaks with a native Go solution.
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
213
etc/swaks-tests/gmail.raw
Normal file
213
etc/swaks-tests/gmail.raw
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
MIME-Version: 1.0
|
||||||
|
Date: %DATE%
|
||||||
|
Message-ID: <CANqLHXqq1i6cOTcEHa7W9hT21ZueMJTM4QaP5DH0YXScqYCEuw@mail.gmail.com>
|
||||||
|
Subject: Test from Gmail
|
||||||
|
From: %FROM_ADDRESS%
|
||||||
|
X-ASG-Orig-Subj: Test from Gmail
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
Content-Type: multipart/mixed; boundary=001a113d2d045cd646051e1383c2
|
||||||
|
X-Barracuda-Spam-Score: 0.50
|
||||||
|
X-Barracuda-Spam-Status: No, SCORE=0.50 using global scores of TAG_LEVEL=2.0 QUARANTINE_LEVEL=1000.0 KILL_LEVEL=5.0 tests=BSF_SC5_SA210e, HTML_MESSAGE, WEIRD_PORT
|
||||||
|
X-Barracuda-Spam-Report: Code version 3.2, rules version 3.2.3.21886
|
||||||
|
Rule breakdown below
|
||||||
|
pts rule name description
|
||||||
|
---- ---------------------- --------------------------------------------------
|
||||||
|
0.50 WEIRD_PORT URI: Uses non-standard port number for HTTP
|
||||||
|
0.00 HTML_MESSAGE BODY: HTML included in message
|
||||||
|
0.00 BSF_SC5_SA210e Custom Rule SA210e
|
||||||
|
|
||||||
|
--001a113d2d045cd646051e1383c2
|
||||||
|
Content-Type: multipart/alternative; boundary=001a113d2d045cd63c051e1383c0
|
||||||
|
|
||||||
|
--001a113d2d045cd63c051e1383c0
|
||||||
|
Content-Type: text/plain; charset=UTF-8
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer at ex
|
||||||
|
urna. Nunc sollicitudin venenatis dolor, eget convallis libero convallis
|
||||||
|
eu. Nulla luctus ligula in magna condimentum, placerat lacinia nulla
|
||||||
|
varius. Curabitur quis placerat enim, et aliquam ligula. In dignissim
|
||||||
|
lectus vel pharetra posuere. Proin imperdiet augue orci, at dapibus urna
|
||||||
|
sagittis vel. In nec arcu placerat, vehicula est vel, sollicitudin erat.
|
||||||
|
Nunc tempor risus lorem, sed bibendum tellus pretium non. Sed vel tortor
|
||||||
|
ante. Donec convallis erat ac mauris mollis feugiat. Donec pharetra ex ac
|
||||||
|
tempus aliquam. Praesent ut purus tristique, pharetra arcu vulputate,
|
||||||
|
eleifend felis.
|
||||||
|
|
||||||
|
Aenean ut porttitor risus, a porta nunc. Donec ligula diam, sagittis at
|
||||||
|
luctus id, luctus sit amet risus. Sed turpis nisl, fermentum vitae nibh
|
||||||
|
non, imperdiet luctus arcu. Fusce vulputate velit porta, rutrum velit nec,
|
||||||
|
semper eros. Suspendisse in dui non nulla lacinia tincidunt. Vivamus sit
|
||||||
|
amet lectus eu velit condimentum gravida. Curabitur vestibulum felis nisl,
|
||||||
|
a sagittis odio faucibus a. Nulla fermentum, ligula in gravida hendrerit,
|
||||||
|
diam dui tempor dolor, id euismod tortor velit at massa. Phasellus et nunc
|
||||||
|
mi. Integer tristique viverra odio vitae auctor. Lorem ipsum dolor sit
|
||||||
|
amet, consectetur adipiscing elit. Nunc rutrum turpis ornare lorem ornare
|
||||||
|
lacinia. Aenean vehicula ante nunc, at dignissim dolor porta eu. In hac
|
||||||
|
habitasse platea dictumst. In hac habitasse platea dictumst. Cras ac ex
|
||||||
|
molestie, pulvinar nisi a, finibus est.
|
||||||
|
|
||||||
|
Integer a eros ut tortor convallis porta ultrices id justo. Maecenas luctus
|
||||||
|
purus id molestie molestie. Vestibulum rutrum consequat porta. Fusce
|
||||||
|
vulputate lacus sed nisl venenatis rhoncus. Aliquam erat volutpat.
|
||||||
|
Suspendisse viverra eros id erat congue, nec convallis arcu volutpat. Etiam
|
||||||
|
non sem nisi.
|
||||||
|
|
||||||
|
Integer at velit sed mauris luctus sagittis. Suspendisse aliquam diam non
|
||||||
|
enim viverra, suscipit fermentum massa accumsan. Cras eget ex justo. Aenean
|
||||||
|
non scelerisque elit. Duis et nulla quis est dignissim bibendum. Quisque
|
||||||
|
mattis dui vitae convallis pellentesque. Mauris arcu dui, aliquet non
|
||||||
|
ligula et, posuere tincidunt felis.
|
||||||
|
|
||||||
|
In id tortor sollicitudin, convallis elit ut, auctor libero. Nunc ut lorem
|
||||||
|
a quam tempor lobortis. Praesent nec dolor ut erat fermentum malesuada. Cum
|
||||||
|
sociis natoque penatibus et magnis dis parturient montes, nascetur
|
||||||
|
ridiculus mus. Nulla facilisi. Vestibulum eget ornare justo. Donec iaculis
|
||||||
|
purus eget massa mattis bibendum. Quisque commodo efficitur magna, ac
|
||||||
|
tempor ipsum ultrices eu. Suspendisse id felis molestie, consequat neque
|
||||||
|
in, vehicula velit. In a dictum dui, non tempor elit. Phasellus luctus nec
|
||||||
|
eros viverra consequat. Aliquam efficitur metus consectetur, rhoncus sem
|
||||||
|
vitae, facilisis arcu. Nullam eget nunc in urna mollis laoreet a at eros.
|
||||||
|
|
||||||
|
--001a113d2d045cd63c051e1383c0
|
||||||
|
Content-Type: text/html; charset=UTF-8
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
<div dir=3D"ltr"><div>Lorem ipsum dolor sit amet, consectetur adipiscing el=
|
||||||
|
it. Integer at ex urna. Nunc sollicitudin venenatis dolor, eget convallis l=
|
||||||
|
ibero convallis eu. Nulla luctus ligula in magna condimentum, placerat laci=
|
||||||
|
nia nulla varius. Curabitur quis placerat enim, et aliquam ligula. In digni=
|
||||||
|
ssim lectus vel pharetra posuere. Proin imperdiet augue orci, at dapibus ur=
|
||||||
|
na sagittis vel. In nec arcu placerat, vehicula est vel, sollicitudin erat.=
|
||||||
|
Nunc tempor risus lorem, sed bibendum tellus pretium non. Sed vel tortor a=
|
||||||
|
nte. Donec convallis erat ac mauris mollis feugiat. Donec pharetra ex ac te=
|
||||||
|
mpus aliquam. Praesent ut purus tristique, pharetra arcu vulputate, eleifen=
|
||||||
|
d felis.</div><div><br></div><div>Aenean ut porttitor risus, a porta nunc. =
|
||||||
|
Donec ligula diam, sagittis at luctus id, luctus sit amet risus. Sed turpis=
|
||||||
|
nisl, fermentum vitae nibh non, imperdiet luctus arcu. Fusce vulputate vel=
|
||||||
|
it porta, rutrum velit nec, semper eros. Suspendisse in dui non nulla lacin=
|
||||||
|
ia tincidunt. Vivamus sit amet lectus eu velit condimentum gravida. Curabit=
|
||||||
|
ur vestibulum felis nisl, a sagittis odio faucibus a. Nulla fermentum, ligu=
|
||||||
|
la in gravida hendrerit, diam dui tempor dolor, id euismod tortor velit at =
|
||||||
|
massa. Phasellus et nunc mi. Integer tristique viverra odio vitae auctor. L=
|
||||||
|
orem ipsum dolor sit amet, consectetur adipiscing elit. Nunc rutrum turpis =
|
||||||
|
ornare lorem ornare lacinia. Aenean vehicula ante nunc, at dignissim dolor =
|
||||||
|
porta eu. In hac habitasse platea dictumst. In hac habitasse platea dictums=
|
||||||
|
t. Cras ac ex molestie, pulvinar nisi a, finibus est.</div><div><br></div><=
|
||||||
|
div>Integer a eros ut tortor convallis porta ultrices id justo. Maecenas lu=
|
||||||
|
ctus purus id molestie molestie. Vestibulum rutrum consequat porta. Fusce v=
|
||||||
|
ulputate lacus sed nisl venenatis rhoncus. Aliquam erat volutpat. Suspendis=
|
||||||
|
se viverra eros id erat congue, nec convallis arcu volutpat. Etiam non sem =
|
||||||
|
nisi.</div><div><br></div><div>Integer at velit sed mauris luctus sagittis.=
|
||||||
|
Suspendisse aliquam diam non enim viverra, suscipit fermentum massa accums=
|
||||||
|
an. Cras eget ex justo. Aenean non scelerisque elit. Duis et nulla quis est=
|
||||||
|
dignissim bibendum. Quisque mattis dui vitae convallis pellentesque. Mauri=
|
||||||
|
s arcu dui, aliquet non ligula et, posuere tincidunt felis.</div><div><br><=
|
||||||
|
/div><div>In id tortor sollicitudin, convallis elit ut, auctor libero. Nunc=
|
||||||
|
ut lorem a quam tempor lobortis. Praesent nec dolor ut erat fermentum male=
|
||||||
|
suada. Cum sociis natoque penatibus et magnis dis parturient montes, nascet=
|
||||||
|
ur ridiculus mus. Nulla facilisi. Vestibulum eget ornare justo. Donec iacul=
|
||||||
|
is purus eget massa mattis bibendum. Quisque commodo efficitur magna, ac te=
|
||||||
|
mpor ipsum ultrices eu. Suspendisse id felis molestie, consequat neque in, =
|
||||||
|
vehicula velit. In a dictum dui, non tempor elit. Phasellus luctus nec eros=
|
||||||
|
viverra consequat. Aliquam efficitur metus consectetur, rhoncus sem vitae,=
|
||||||
|
facilisis arcu. Nullam eget nunc in urna mollis laoreet a at eros.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
--001a113d2d045cd63c051e1383c0--
|
||||||
|
--001a113d2d045cd646051e1383c2
|
||||||
|
Content-Type: text/plain; charset=US-ASCII; name="README.txt"
|
||||||
|
Content-Disposition: attachment; filename="README.txt"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
X-Attachment-Id: f_idqb67l61
|
||||||
|
|
||||||
|
SW5idWNrZXQgWyFbQnVpbGQgU3RhdHVzXShodHRwczovL3RyYXZpcy1jaS5vcmcvamhpbGx5ZXJk
|
||||||
|
L2luYnVja2V0LnBuZz9icmFuY2g9bWFzdGVyKV0oaHR0cHM6Ly90cmF2aXMtY2kub3JnL2poaWxs
|
||||||
|
eWVyZC9pbmJ1Y2tldCkKPT09PT09PT0KCkluYnVja2V0IGlzIGFuIGVtYWlsIHRlc3Rpbmcgc2Vy
|
||||||
|
dmljZTsgaXQgd2lsbCBhY2NlcHQgbWVzc2FnZXMgZm9yIGFueSBlbWFpbAphZGRyZXNzIGFuZCBt
|
||||||
|
YWtlIHRoZW0gYXZhaWxhYmxlIHZpYSB3ZWIsIFJFU1QgYW5kIFBPUDMuICBPbmNlIGNvbXBpbGVk
|
||||||
|
LApJbmJ1Y2tldCBkb2VzIG5vdCBoYXZlIGFuIGV4dGVybmFsIGRlcGVuZGVuY2llcyAoSFRUUCwg
|
||||||
|
U01UUCwgUE9QMyBhbmQgc3RvcmFnZQphcmUgYWxsIGJ1aWx0IGluKS4KClJlYWQgbW9yZSBhdCB0
|
||||||
|
aGUgW0luYnVja2V0IHdlYnNpdGVdW0luYnVja2V0XQoKRGV2ZWxvcG1lbnQgU3RhdHVzCi0tLS0t
|
||||||
|
LS0tLS0tLS0tLS0tLQoKSW5idWNrZXQgaXMgY3VycmVudGx5IHByb2R1Y3Rpb24gcXVhbGl0eTog
|
||||||
|
aXQgaXMgYmVpbmcgdXNlZCBmb3IgcmVhbCB3b3JrLgoKUGxlYXNlIGNoZWNrIHRoZSBbaXNzdWVz
|
||||||
|
IGxpc3RdW0lzc3Vlc10KZm9yIG1vcmUgZGV0YWlscy4KCkJ1aWxkaW5nIGZyb20gU291cmNlCi0t
|
||||||
|
LS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKWW91IHdpbGwgbmVlZCBhIGZ1bmN0aW9uaW5nIFtHbyBp
|
||||||
|
bnN0YWxsYXRpb25dW0dvbGFuZ10gZm9yIHRoaXMgdG8gd29yay4KCkdyYWIgdGhlIEluYnVja2V0
|
||||||
|
IHNvdXJjZSBjb2RlIGFuZCBjb21waWxlIHRoZSBkYWVtb246CgogICAgZ28gZ2V0IC12IGdpdGh1
|
||||||
|
Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0CgpFZGl0IGV0Yy9pbmJ1Y2tldC5jb25mIGFuZCB0YWls
|
||||||
|
b3IgdG8geW91ciBlbnZpcm9ubWVudC4gIEl0IHNob3VsZCB3b3JrIG9uIG1vc3QKVW5peCBhbmQg
|
||||||
|
T1MgWCBtYWNoaW5lcyBhcyBpcy4gIExhdW5jaCB0aGUgZGFlbW9uOgoKICAgICRHT1BBVEgvYmlu
|
||||||
|
L2luYnVja2V0ICRHT1BBVEgvc3JjL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0L2V0Yy9p
|
||||||
|
bmJ1Y2tldC5jb25mCgpCeSBkZWZhdWx0IHRoZSBTTVRQIHNlcnZlciB3aWxsIGJlIGxpc3Rlbmlu
|
||||||
|
ZyBvbiBsb2NhbGhvc3QgcG9ydCAyNTAwIGFuZAp0aGUgd2ViIGludGVyZmFjZSB3aWxsIGJlIGF2
|
||||||
|
YWlsYWJsZSBhdCBbbG9jYWxob3N0OjkwMDBdKGh0dHA6Ly9sb2NhbGhvc3Q6OTAwMC8pLgoKVGhl
|
||||||
|
IEluYnVja2V0IHdlYnNpdGUgaGFzIGEgbW9yZSBjb21wbGV0ZSBndWlkZSB0bwpbaW5zdGFsbGlu
|
||||||
|
ZyBmcm9tIHNvdXJjZV1bRnJvbSBTb3VyY2VdCgpBYm91dAotLS0tLQoKSW5idWNrZXQgaXMgd3Jp
|
||||||
|
dHRlbiBpbiBbR29vZ2xlIEdvXVtHb2xhbmddLgoKSW5idWNrZXQgaXMgb3BlbiBzb3VyY2Ugc29m
|
||||||
|
dHdhcmUgcmVsZWFzZWQgdW5kZXIgdGhlIE1JVCBMaWNlbnNlLiAgVGhlIGxhdGVzdAp2ZXJzaW9u
|
||||||
|
IGNhbiBiZSBmb3VuZCBhdCBodHRwczovL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0Cgpb
|
||||||
|
SW5idWNrZXRdOiBodHRwOi8vd3d3LmluYnVja2V0Lm9yZy8KW0lzc3Vlc106IGh0dHBzOi8vZ2l0
|
||||||
|
aHViLmNvbS9qaGlsbHllcmQvaW5idWNrZXQvaXNzdWVzP3N0YXRlPW9wZW4KW0Zyb20gU291cmNl
|
||||||
|
XTogaHR0cDovL3d3dy5pbmJ1Y2tldC5vcmcvaW5zdGFsbGF0aW9uL2Zyb20tc291cmNlLmh0bWwK
|
||||||
|
W0dvbGFuZ106IGh0dHA6Ly9nb2xhbmcub3JnLwo=
|
||||||
|
--001a113d2d045cd646051e1383c2
|
||||||
|
Content-Type: image/png; name="favicon.png"
|
||||||
|
Content-Disposition: attachment; filename="favicon.png"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
X-Attachment-Id: f_idqb60e30
|
||||||
|
|
||||||
|
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAK
|
||||||
|
T2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AU
|
||||||
|
kSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXX
|
||||||
|
Pues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgAB
|
||||||
|
eNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAt
|
||||||
|
AGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3
|
||||||
|
AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dX
|
||||||
|
Lh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+
|
||||||
|
5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk
|
||||||
|
5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd
|
||||||
|
0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA
|
||||||
|
4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzA
|
||||||
|
BhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/ph
|
||||||
|
CJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5
|
||||||
|
h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+
|
||||||
|
Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhM
|
||||||
|
WE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQ
|
||||||
|
AkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+Io
|
||||||
|
UspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdp
|
||||||
|
r+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZ
|
||||||
|
D5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61Mb
|
||||||
|
U2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY
|
||||||
|
/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllir
|
||||||
|
SKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79u
|
||||||
|
p+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6Vh
|
||||||
|
lWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1
|
||||||
|
mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lO
|
||||||
|
k06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7Ry
|
||||||
|
FDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3I
|
||||||
|
veRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+B
|
||||||
|
Z7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/
|
||||||
|
0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5p
|
||||||
|
DoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5q
|
||||||
|
PNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIs
|
||||||
|
OpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5
|
||||||
|
hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQ
|
||||||
|
rAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9
|
||||||
|
rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1d
|
||||||
|
T1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aX
|
||||||
|
Dm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7
|
||||||
|
vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3S
|
||||||
|
PVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKa
|
||||||
|
RptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO
|
||||||
|
32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21
|
||||||
|
e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfV
|
||||||
|
P1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i
|
||||||
|
/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8
|
||||||
|
IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADq
|
||||||
|
YAAAOpgAABdvkl/FRgAAAPxJREFUeNqkU0ERgzAQ3DA1EAuxgAUsYAELsYCFWKASqASQUCRQCdfP
|
||||||
|
HuxkYProzRxJLsmyd3sJZoZ/7AEAIYQ63gLo6G4vAE8AmwfMjJ/TIoAJgNFn+iKxfAcQeXDnoVix
|
||||||
|
SgKerwAKN1v6LCwGGSf+JCpAEuTIA355F/o9wQ3AoACZwSRzz3UQgOTpA8gKMAF4c9Pr4NYJgNfq
|
||||||
|
AGgkuImEayWpywimcaybm/5IAjxwvnKdeXk9tQRGoehSLUxLe8JjsZaxq1RwCUcB3yl1vGskl6z9
|
||||||
|
0f69M6oBklAu1TtwlkXYpLu3UCTvKy9ag2BmV68xSku7baz+R2vwHQC+QKj9KkHDLAAAAABJRU5E
|
||||||
|
rkJggg==
|
||||||
|
--001a113d2d045cd646051e1383c2--
|
||||||
31
etc/swaks-tests/mime-errors.raw
Normal file
31
etc/swaks-tests/mime-errors.raw
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
Date: %DATE%
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
From: %FROM_ADDRESS%
|
||||||
|
Subject: MIME Errors
|
||||||
|
Message-Id: <07B7061D-2676-487E-942E-C341CE4D13DC@makita.skynet>
|
||||||
|
Content-Type: multipart/alternative; boundary="Enmime-Test-100"
|
||||||
|
|
||||||
|
--Enmime-Test-100
|
||||||
|
Content-Transfer-Encoding: 8bit
|
||||||
|
Content-Type: text/plain; charset=us-ascii
|
||||||
|
|
||||||
|
Using Unicode/UTF-8, you can write in emails and source code things such as
|
||||||
|
|
||||||
|
Mathematics and sciences:
|
||||||
|
|
||||||
|
∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i), ⎧⎡⎛┌─────┐⎞⎤⎫
|
||||||
|
⎪⎢⎜│a²+b³ ⎟⎥⎪
|
||||||
|
∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β), ⎪⎢⎜│───── ⎟⎥⎪
|
||||||
|
⎪⎢⎜⎷ c₈ ⎟⎥⎪
|
||||||
|
ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ, ⎨⎢⎜ ⎟⎥⎬
|
||||||
|
⎪⎢⎜ ∞ ⎟⎥⎪
|
||||||
|
⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫), ⎪⎢⎜ ⎲ ⎟⎥⎪
|
||||||
|
⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
|
||||||
|
2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm ⎩⎣⎝i=1 ⎠⎦⎭
|
||||||
|
|
||||||
|
Linguistics and dictionaries:
|
||||||
|
|
||||||
|
ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
|
||||||
|
Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
|
||||||
|
|
||||||
|
--Enmime-Test-100
|
||||||
393
etc/swaks-tests/nonmime-html-inlined.raw
Normal file
393
etc/swaks-tests/nonmime-html-inlined.raw
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
Date: %DATE%
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
From: %FROM_ADDRESS%
|
||||||
|
Subject: tutsplus responsive inlined CSS
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title></title>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<style type="text/css">
|
||||||
|
table {border-collapse: collapse !important;}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;background-color:#ffffff;" >
|
||||||
|
<center class="wrapper" style="width:100%;table-layout:fixed;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;" >
|
||||||
|
<div class="webkit" style="max-width:600px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;" >
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="600" align="center" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<table class="outer" align="center" style="border-spacing:0;font-family:sans-serif;color:#333333;Margin:0 auto;width:100%;max-width:600px;" >
|
||||||
|
<tr>
|
||||||
|
<td class="full-width-image" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/header.jpg" width="600" alt="" style="border-width:0;width:100%;max-width:600px;height:auto;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="one-column" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;text-align:left;" >
|
||||||
|
<p class="h1" style="Margin:0;font-weight:bold;font-size:14px;Margin-bottom:10px;" >Lorem ipsum dolor sit amet</p>
|
||||||
|
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >
|
||||||
|
Compare to:
|
||||||
|
<a href="http://tutsplus.github.io/creating-a-future-proof-responsive-email-without-media-queries/index.html" style="color:#ee6a56;text-decoration:underline;" >
|
||||||
|
tutsplus sample</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >Copyright (c) 2015, Envato Tuts+<br/>
|
||||||
|
All rights reserved.</p>
|
||||||
|
|
||||||
|
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.</li>
|
||||||
|
|
||||||
|
<li>Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="two-column" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:300px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||||
|
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:left;" >
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/two-column-01.jpg" width="280" alt="" style="border-width:0;width:100%;max-width:280px;height:auto;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||||
|
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="50%" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:300px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||||
|
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:left;" >
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/two-column-02.jpg" width="280" alt="" style="border-width:0;width:100%;max-width:280px;height:auto;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||||
|
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="three-column" style="padding-right:0;padding-left:0;text-align:center;font-size:0;padding-top:10px;padding-bottom:10px;" >
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||||
|
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/three-column-01.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||||
|
Scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||||
|
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/three-column-02.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||||
|
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||||
|
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/three-column-03.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||||
|
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="three-column" style="padding-right:0;padding-left:0;text-align:center;font-size:0;padding-top:10px;padding-bottom:10px;" >
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Fashion</p>
|
||||||
|
<p style="Margin:0;" >Class eleifend aptent taciti sociosqu ad litora torquent conubia</p>
|
||||||
|
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Read requirements</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Photography</p>
|
||||||
|
<p style="Margin:0;" >Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||||
|
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See examples</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Design</p>
|
||||||
|
<p style="Margin:0;" >Class aptent taciti sociosqu eleifend ad litora per conubia nostra</p>
|
||||||
|
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See the winners</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Cooking</p>
|
||||||
|
<p style="Margin:0;" >Class aptent taciti eleifend sociosqu ad litora torquent conubia</p>
|
||||||
|
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Read recipes</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Woodworking</p>
|
||||||
|
<p style="Margin:0;" >Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||||
|
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See examples</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Craft</p>
|
||||||
|
<p style="Margin:0;" >Class aptent taciti sociosqu ad eleifend litora per conubia nostra</p>
|
||||||
|
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Vote now</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="left-sidebar" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td width="100" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column left" style="width:100%;display:inline-block;vertical-align:middle;max-width:100px;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-01.jpg" width="80" alt="" style="border-width:0;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="500" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column right" style="width:100%;display:inline-block;vertical-align:middle;max-width:500px;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat. <a href="#" style="text-decoration:underline;color:#85ab70;" >Read on</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="right-sidebar" dir="rtl" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%" dir="rtl" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td width="100" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column left" dir="ltr" style="width:100%;display:inline-block;vertical-align:middle;max-width:100px;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-02.jpg" width="80" alt="" style="border-width:0;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="500" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column right" dir="ltr" style="width:100%;display:inline-block;vertical-align:middle;max-width:500px;" >
|
||||||
|
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||||
|
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra. <a href="#" style="text-decoration:underline;color:#70bbd9;" >Per inceptos</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</div>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
394
etc/swaks-tests/nonmime-html-responsive.raw
Normal file
394
etc/swaks-tests/nonmime-html-responsive.raw
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
Date: %DATE%
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
From: %FROM_ADDRESS%
|
||||||
|
Subject: tutsplus responsive external CSS
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title></title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="http://www.inbucket.org/email-assets/responsive/styles.css" />
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<style type="text/css">
|
||||||
|
table {border-collapse: collapse;}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center class="wrapper">
|
||||||
|
<div class="webkit">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="600" align="center">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<![endif]-->
|
||||||
|
<table class="outer" align="center">
|
||||||
|
<tr>
|
||||||
|
<td class="full-width-image">
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/header.jpg" width="600" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="one-column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h1">Lorem ipsum dolor sit amet</p>
|
||||||
|
<p>
|
||||||
|
Compare to:
|
||||||
|
<a href="http://tutsplus.github.io/creating-a-future-proof-responsive-email-without-media-queries/index.html">
|
||||||
|
tutsplus sample</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Copyright (c) 2015, Envato Tuts+<br/>
|
||||||
|
All rights reserved.</p>
|
||||||
|
|
||||||
|
<p>Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.</li>
|
||||||
|
|
||||||
|
<li>Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="two-column">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner">
|
||||||
|
<table class="contents">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/two-column-01.jpg" width="280" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text">
|
||||||
|
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="50%" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner">
|
||||||
|
<table class="contents">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/two-column-02.jpg" width="280" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text">
|
||||||
|
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="three-column">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner">
|
||||||
|
<table class="contents">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/three-column-01.jpg" width="180" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text">
|
||||||
|
Scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner">
|
||||||
|
<table class="contents">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/three-column-02.jpg" width="180" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text">
|
||||||
|
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner">
|
||||||
|
<table class="contents">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/three-column-03.jpg" width="180" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text">
|
||||||
|
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="three-column">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h2">Fashion</p>
|
||||||
|
<p>Class eleifend aptent taciti sociosqu ad litora torquent conubia</p>
|
||||||
|
<p><a href="#">Read requirements</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h2">Photography</p>
|
||||||
|
<p>Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||||
|
<p><a href="#">See examples</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h2">Design</p>
|
||||||
|
<p>Class aptent taciti sociosqu eleifend ad litora per conubia nostra</p>
|
||||||
|
<p><a href="#">See the winners</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h2">Cooking</p>
|
||||||
|
<p>Class aptent taciti eleifend sociosqu ad litora torquent conubia</p>
|
||||||
|
<p><a href="#">Read recipes</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h2">Woodworking</p>
|
||||||
|
<p>Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||||
|
<p><a href="#">See examples</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="200" valign="top">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<p class="h2">Craft</p>
|
||||||
|
<p>Class aptent taciti sociosqu ad eleifend litora per conubia nostra</p>
|
||||||
|
<p><a href="#">Vote now</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="left-sidebar">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td width="100">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column left">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner">
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-01.jpg" width="80" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="500">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column right">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat. <a href="#">Read on</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="right-sidebar" dir="rtl">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table width="100%" dir="rtl">
|
||||||
|
<tr>
|
||||||
|
<td width="100">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column left" dir="ltr">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-02.jpg" width="80" alt="" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td><td width="500">
|
||||||
|
<![endif]-->
|
||||||
|
<div class="column right" dir="ltr">
|
||||||
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
|
<td class="inner contents">
|
||||||
|
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra. <a href="#">Per inceptos</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</div>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
etc/swaks-tests/nonmime-html.raw
Normal file
10
etc/swaks-tests/nonmime-html.raw
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Date: %DATE%
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
From: %FROM_ADDRESS%
|
||||||
|
Subject: Swaks HTML
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<p style="font-family: 'Courier New', Courier, monospace;">
|
||||||
|
This is a test of <b>HTML</b> at the <i>top</i> level.
|
||||||
|
</p>
|
||||||
322
etc/swaks-tests/outlook.raw
Normal file
322
etc/swaks-tests/outlook.raw
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
From: %FROM_ADDRESS%
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
Subject: Test from Outlook
|
||||||
|
Thread-Topic: Test from Outlook
|
||||||
|
Thread-Index: AdDeqI993CvUm800TamFq90sKu975w==
|
||||||
|
Date: %DATE%
|
||||||
|
Message-ID: <8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADD@exch.com>
|
||||||
|
Accept-Language: en-US
|
||||||
|
Content-Language: en-US
|
||||||
|
X-MS-Has-Attach: yes
|
||||||
|
X-MS-TNEF-Correlator:
|
||||||
|
x-originating-ip: [10.13.30.10]
|
||||||
|
Content-Type: multipart/mixed;
|
||||||
|
boundary="_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_"
|
||||||
|
MIME-Version: 1.0
|
||||||
|
|
||||||
|
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||||
|
Content-Type: multipart/alternative;
|
||||||
|
boundary="_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_"
|
||||||
|
|
||||||
|
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||||
|
Content-Type: text/plain; charset="us-ascii"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque liber=
|
||||||
|
o arcu, accumsan at mattis nec, condimentum vitae erat. Praesent massa turp=
|
||||||
|
is, iaculis elementum lectus vitae, iaculis laoreet massa. Integer porta, n=
|
||||||
|
isi eget congue vulputate, tellus lacus imperdiet tellus, in tristique metu=
|
||||||
|
s nibh sed est. Vestibulum ullamcorper arcu sed lacus viverra tristique. Ve=
|
||||||
|
stibulum mattis id ante eget aliquam. Nam pulvinar, libero dignissim posuer=
|
||||||
|
e tincidunt, ipsum est finibus enim, id accumsan augue lectus eu massa. In =
|
||||||
|
dapibus consequat velit quis interdum. Cras vel augue pellentesque tortor i=
|
||||||
|
nterdum molestie. Fusce ut dui semper, ultricies nulla a, tempor lacus. In =
|
||||||
|
interdum velit in justo dapibus iaculis. Quisque et neque turpis. Aenean id=
|
||||||
|
nunc sodales, ultrices turpis non, molestie nibh.
|
||||||
|
|
||||||
|
In euismod aliquam tortor ac ornare. Donec nisi ante, lacinia eget placerat=
|
||||||
|
at, tincidunt id ante. Vestibulum mauris nisi, consectetur vitae dolor qui=
|
||||||
|
s, maximus auctor tellus. In dignissim mi blandit, laoreet mi vitae, gravid=
|
||||||
|
a risus. Sed rhoncus nisi velit, at condimentum velit efficitur id. Duis ia=
|
||||||
|
culis dictum tempor. Ut consectetur nisi in ex viverra interdum. Cras eget =
|
||||||
|
vestibulum libero. Vestibulum in efficitur ante, id tristique elit. Aliquam=
|
||||||
|
justo dolor, sagittis et dui vitae, gravida pellentesque lectus. Sed venen=
|
||||||
|
atis imperdiet cursus. Nulla quis nulla eu nisi tempor varius et ut elit. I=
|
||||||
|
nterdum et malesuada fames ac ante ipsum primis in faucibus. Mauris placera=
|
||||||
|
t interdum eros, vitae molestie urna consequat et.
|
||||||
|
|
||||||
|
Cras massa dolor, congue eu magna et, sagittis eleifend quam. Donec volutpa=
|
||||||
|
t congue leo, in sodales purus mollis nec. Integer vehicula, odio eget cong=
|
||||||
|
ue interdum, erat nunc interdum nisi, quis congue nunc felis eu ipsum. Sed =
|
||||||
|
bibendum massa dui, et sodales lacus dignissim ut. Nulla et orci vitae lacu=
|
||||||
|
s dignissim elementum eu sollicitudin turpis. Phasellus leo lorem, pellente=
|
||||||
|
sque sit amet ultricies varius, bibendum sed ligula. Nam eros orci, facilis=
|
||||||
|
is vel lacus vitae, suscipit tincidunt nibh. In id magna a velit molestie a=
|
||||||
|
uctor. Etiam a nunc ligula. Sed hendrerit, felis quis pharetra bibendum, to=
|
||||||
|
rtor sem tempor lacus, et hendrerit quam nisl ac metus. Ut convallis congue=
|
||||||
|
lectus, eu scelerisque nisl pharetra sed. Vestibulum ante ipsum primis in =
|
||||||
|
faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam ut turpis n=
|
||||||
|
isi. Phasellus ac dolor laoreet, commodo turpis quis, ultricies turpis. Qui=
|
||||||
|
sque mollis lorem vestibulum diam sollicitudin, in tempor mi accumsan. Proi=
|
||||||
|
n metus nunc, fringilla eu sagittis nec, rhoncus semper sem.
|
||||||
|
|
||||||
|
Fusce posuere et felis ut ornare. Proin sodales sollicitudin tellus, non tr=
|
||||||
|
istique ex egestas at. Pellentesque arcu sem, vulputate a diam eget, blandi=
|
||||||
|
t lobortis libero. Donec accumsan, diam vel congue hendrerit, mi mi vestibu=
|
||||||
|
lum ex, ut elementum est magna eu augue. Aliquam consequat arcu eu velit tr=
|
||||||
|
istique placerat. Quisque facilisis tempor ipsum, sit amet iaculis nunc mal=
|
||||||
|
esuada a. Morbi laoreet fringilla odio sed volutpat. Integer scelerisque in=
|
||||||
|
terdum massa et fringilla. Nam pulvinar iaculis nibh, id condimentum nunc. =
|
||||||
|
Integer eleifend, dui in iaculis sollicitudin, lacus dui feugiat felis, ut =
|
||||||
|
efficitur urna sem at neque. Donec at sapien malesuada orci imperdiet eleme=
|
||||||
|
ntum. Nulla luctus tristique enim, eu euismod diam ultricies vel. Ut dui pu=
|
||||||
|
rus, commodo eu tristique nec, accumsan vel lorem. Phasellus sagittis ullam=
|
||||||
|
corper vulputate. Vestibulum sed nulla tristique, tristique lectus quis, pu=
|
||||||
|
lvinar quam.
|
||||||
|
|
||||||
|
Duis sollicitudin convallis lacinia. Maecenas erat eros, laoreet ut sapien =
|
||||||
|
vitae, convallis semper neque. Ut est turpis, pharetra sed tortor id, luctu=
|
||||||
|
s pretium odio. Mauris laoreet dapibus iaculis. Pellentesque augue tortor, =
|
||||||
|
lacinia eget mauris eu, hendrerit ultricies turpis. Vestibulum suscipit nis=
|
||||||
|
i ligula, at tempus augue vulputate ac. Vestibulum leo mauris, tristique se=
|
||||||
|
d cursus a, sodales a erat. Sed accumsan justo at dui ornare, at placerat a=
|
||||||
|
ugue vestibulum.
|
||||||
|
|
||||||
|
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||||
|
Content-Type: text/html; charset="us-ascii"
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
<html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-micr=
|
||||||
|
osoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:office:word" =
|
||||||
|
xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xmlns=3D"http:=
|
||||||
|
//www.w3.org/TR/REC-html40">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dus-ascii"=
|
||||||
|
>
|
||||||
|
<meta name=3D"Generator" content=3D"Microsoft Word 15 (filtered medium)">
|
||||||
|
<style><!--
|
||||||
|
/* Font Definitions */
|
||||||
|
@font-face
|
||||||
|
{font-family:"MS Mincho";
|
||||||
|
panose-1:2 2 6 9 4 2 5 8 3 4;}
|
||||||
|
@font-face
|
||||||
|
{font-family:"Cambria Math";
|
||||||
|
panose-1:2 4 5 3 5 4 6 3 2 4;}
|
||||||
|
@font-face
|
||||||
|
{font-family:Calibri;
|
||||||
|
panose-1:2 15 5 2 2 2 4 3 2 4;}
|
||||||
|
@font-face
|
||||||
|
{font-family:"\@MS Mincho";
|
||||||
|
panose-1:2 2 6 9 4 2 5 8 3 4;}
|
||||||
|
/* Style Definitions */
|
||||||
|
p.MsoNormal, li.MsoNormal, div.MsoNormal
|
||||||
|
{margin:0in;
|
||||||
|
margin-bottom:.0001pt;
|
||||||
|
font-size:11.0pt;
|
||||||
|
font-family:"Calibri",sans-serif;}
|
||||||
|
a:link, span.MsoHyperlink
|
||||||
|
{mso-style-priority:99;
|
||||||
|
color:#0563C1;
|
||||||
|
text-decoration:underline;}
|
||||||
|
a:visited, span.MsoHyperlinkFollowed
|
||||||
|
{mso-style-priority:99;
|
||||||
|
color:#954F72;
|
||||||
|
text-decoration:underline;}
|
||||||
|
span.EmailStyle17
|
||||||
|
{mso-style-type:personal-compose;
|
||||||
|
font-family:"Calibri",sans-serif;
|
||||||
|
color:windowtext;}
|
||||||
|
.MsoChpDefault
|
||||||
|
{mso-style-type:export-only;}
|
||||||
|
@page WordSection1
|
||||||
|
{size:8.5in 11.0in;
|
||||||
|
margin:1.0in 1.0in 1.0in 1.0in;}
|
||||||
|
div.WordSection1
|
||||||
|
{page:WordSection1;}
|
||||||
|
--></style><!--[if gte mso 9]><xml>
|
||||||
|
<o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
|
||||||
|
</xml><![endif]--><!--[if gte mso 9]><xml>
|
||||||
|
<o:shapelayout v:ext=3D"edit">
|
||||||
|
<o:idmap v:ext=3D"edit" data=3D"1" />
|
||||||
|
</o:shapelayout></xml><![endif]-->
|
||||||
|
</head>
|
||||||
|
<body lang=3D"EN-US" link=3D"#0563C1" vlink=3D"#954F72">
|
||||||
|
<div class=3D"WordSection1">
|
||||||
|
<p class=3D"MsoNormal">Lorem ipsum dolor sit amet, consectetur adipiscing e=
|
||||||
|
lit. Pellentesque libero arcu, accumsan at mattis nec, condimentum vitae er=
|
||||||
|
at. Praesent massa turpis, iaculis elementum lectus vitae, iaculis laoreet =
|
||||||
|
massa. Integer porta, nisi eget congue
|
||||||
|
vulputate, tellus lacus imperdiet tellus, in tristique metus nibh sed est.=
|
||||||
|
Vestibulum ullamcorper arcu sed lacus viverra tristique. Vestibulum mattis=
|
||||||
|
id ante eget aliquam. Nam pulvinar, libero dignissim posuere tincidunt, ip=
|
||||||
|
sum est finibus enim, id accumsan
|
||||||
|
augue lectus eu massa. In dapibus consequat velit quis interdum. Cras vel =
|
||||||
|
augue pellentesque tortor interdum molestie. Fusce ut dui semper, ultricies=
|
||||||
|
nulla a, tempor lacus. In interdum velit in justo dapibus iaculis. Quisque=
|
||||||
|
et neque turpis. Aenean id nunc
|
||||||
|
sodales, ultrices turpis non, molestie nibh.<o:p></o:p></p>
|
||||||
|
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||||
|
<p class=3D"MsoNormal">In euismod aliquam tortor ac ornare. Donec nisi ante=
|
||||||
|
, lacinia eget placerat at, tincidunt id ante. Vestibulum mauris nisi, cons=
|
||||||
|
ectetur vitae dolor quis, maximus auctor tellus. In dignissim mi blandit, l=
|
||||||
|
aoreet mi vitae, gravida risus. Sed
|
||||||
|
rhoncus nisi velit, at condimentum velit efficitur id. Duis iaculis dictum=
|
||||||
|
tempor. Ut consectetur nisi in ex viverra interdum. Cras eget vestibulum l=
|
||||||
|
ibero. Vestibulum in efficitur ante, id tristique elit. Aliquam justo dolor=
|
||||||
|
, sagittis et dui vitae, gravida
|
||||||
|
pellentesque lectus. Sed venenatis imperdiet cursus. Nulla quis nulla eu n=
|
||||||
|
isi tempor varius et ut elit. Interdum et malesuada fames ac ante ipsum pri=
|
||||||
|
mis in faucibus. Mauris placerat interdum eros, vitae molestie urna consequ=
|
||||||
|
at et.<o:p></o:p></p>
|
||||||
|
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||||
|
<p class=3D"MsoNormal">Cras massa dolor, congue eu magna et, sagittis eleif=
|
||||||
|
end quam. Donec volutpat congue leo, in sodales purus mollis nec. Integer v=
|
||||||
|
ehicula, odio eget congue interdum, erat nunc interdum nisi, quis congue nu=
|
||||||
|
nc felis eu ipsum. Sed bibendum massa
|
||||||
|
dui, et sodales lacus dignissim ut. Nulla et orci vitae lacus dignissim el=
|
||||||
|
ementum eu sollicitudin turpis. Phasellus leo lorem, pellentesque sit amet =
|
||||||
|
ultricies varius, bibendum sed ligula. Nam eros orci, facilisis vel lacus v=
|
||||||
|
itae, suscipit tincidunt nibh. In
|
||||||
|
id magna a velit molestie auctor. Etiam a nunc ligula. Sed hendrerit, feli=
|
||||||
|
s quis pharetra bibendum, tortor sem tempor lacus, et hendrerit quam nisl a=
|
||||||
|
c metus. Ut convallis congue lectus, eu scelerisque nisl pharetra sed. Vest=
|
||||||
|
ibulum ante ipsum primis in faucibus
|
||||||
|
orci luctus et ultrices posuere cubilia Curae; Aliquam ut turpis nisi. Pha=
|
||||||
|
sellus ac dolor laoreet, commodo turpis quis, ultricies turpis. Quisque mol=
|
||||||
|
lis lorem vestibulum diam sollicitudin, in tempor mi accumsan. Proin metus =
|
||||||
|
nunc, fringilla eu sagittis nec,
|
||||||
|
rhoncus semper sem.<o:p></o:p></p>
|
||||||
|
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||||
|
<p class=3D"MsoNormal">Fusce posuere et felis ut ornare. Proin sodales soll=
|
||||||
|
icitudin tellus, non tristique ex egestas at. Pellentesque arcu sem, vulput=
|
||||||
|
ate a diam eget, blandit lobortis libero. Donec accumsan, diam vel congue h=
|
||||||
|
endrerit, mi mi vestibulum ex, ut
|
||||||
|
elementum est magna eu augue. Aliquam consequat arcu eu velit tristique pl=
|
||||||
|
acerat. Quisque facilisis tempor ipsum, sit amet iaculis nunc malesuada a. =
|
||||||
|
Morbi laoreet fringilla odio sed volutpat. Integer scelerisque interdum mas=
|
||||||
|
sa et fringilla. Nam pulvinar iaculis
|
||||||
|
nibh, id condimentum nunc. Integer eleifend, dui in iaculis sollicitudin, =
|
||||||
|
lacus dui feugiat felis, ut efficitur urna sem at neque. Donec at sapien ma=
|
||||||
|
lesuada orci imperdiet elementum. Nulla luctus tristique enim, eu euismod d=
|
||||||
|
iam ultricies vel. Ut dui purus,
|
||||||
|
commodo eu tristique nec, accumsan vel lorem. Phasellus sagittis ullamcorp=
|
||||||
|
er vulputate. Vestibulum sed nulla tristique, tristique lectus quis, pulvin=
|
||||||
|
ar quam.<o:p></o:p></p>
|
||||||
|
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||||
|
<p class=3D"MsoNormal">Duis sollicitudin convallis lacinia. Maecenas erat e=
|
||||||
|
ros, laoreet ut sapien vitae, convallis semper neque. Ut est turpis, pharet=
|
||||||
|
ra sed tortor id, luctus pretium odio. Mauris laoreet dapibus iaculis. Pell=
|
||||||
|
entesque augue tortor, lacinia eget
|
||||||
|
mauris eu, hendrerit ultricies turpis. Vestibulum suscipit nisi ligula, at=
|
||||||
|
tempus augue vulputate ac. Vestibulum leo mauris, tristique sed cursus a, =
|
||||||
|
sodales a erat. Sed accumsan justo at dui ornare, at placerat augue vestibu=
|
||||||
|
lum.<o:p></o:p></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_--
|
||||||
|
|
||||||
|
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||||
|
Content-Type: image/png; name="favicon.png"
|
||||||
|
Content-Description: favicon.png
|
||||||
|
Content-Disposition: attachment; filename="favicon.png"; size=3025;
|
||||||
|
creation-date="Mon, 24 Aug 2015 19:05:03 GMT";
|
||||||
|
modification-date="Mon, 24 Aug 2015 19:05:03 GMT"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAK
|
||||||
|
T2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AU
|
||||||
|
kSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXX
|
||||||
|
Pues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgAB
|
||||||
|
eNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAt
|
||||||
|
AGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3
|
||||||
|
AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dX
|
||||||
|
Lh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+
|
||||||
|
5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk
|
||||||
|
5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd
|
||||||
|
0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA
|
||||||
|
4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzA
|
||||||
|
BhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/ph
|
||||||
|
CJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5
|
||||||
|
h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+
|
||||||
|
Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhM
|
||||||
|
WE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQ
|
||||||
|
AkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+Io
|
||||||
|
UspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdp
|
||||||
|
r+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZ
|
||||||
|
D5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61Mb
|
||||||
|
U2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY
|
||||||
|
/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllir
|
||||||
|
SKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79u
|
||||||
|
p+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6Vh
|
||||||
|
lWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1
|
||||||
|
mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lO
|
||||||
|
k06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7Ry
|
||||||
|
FDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3I
|
||||||
|
veRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+B
|
||||||
|
Z7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/
|
||||||
|
0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5p
|
||||||
|
DoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5q
|
||||||
|
PNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIs
|
||||||
|
OpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5
|
||||||
|
hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQ
|
||||||
|
rAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9
|
||||||
|
rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1d
|
||||||
|
T1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aX
|
||||||
|
Dm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7
|
||||||
|
vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3S
|
||||||
|
PVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKa
|
||||||
|
RptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO
|
||||||
|
32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21
|
||||||
|
e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfV
|
||||||
|
P1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i
|
||||||
|
/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8
|
||||||
|
IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADq
|
||||||
|
YAAAOpgAABdvkl/FRgAAAPxJREFUeNqkU0ERgzAQ3DA1EAuxgAUsYAELsYCFWKASqASQUCRQCdfP
|
||||||
|
HuxkYProzRxJLsmyd3sJZoZ/7AEAIYQ63gLo6G4vAE8AmwfMjJ/TIoAJgNFn+iKxfAcQeXDnoVix
|
||||||
|
SgKerwAKN1v6LCwGGSf+JCpAEuTIA355F/o9wQ3AoACZwSRzz3UQgOTpA8gKMAF4c9Pr4NYJgNfq
|
||||||
|
AGgkuImEayWpywimcaybm/5IAjxwvnKdeXk9tQRGoehSLUxLe8JjsZaxq1RwCUcB3yl1vGskl6z9
|
||||||
|
0f69M6oBklAu1TtwlkXYpLu3UCTvKy9ag2BmV68xSku7baz+R2vwHQC+QKj9KkHDLAAAAABJRU5E
|
||||||
|
rkJggg==
|
||||||
|
|
||||||
|
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||||
|
Content-Type: text/plain; name="README.txt"
|
||||||
|
Content-Description: README.txt
|
||||||
|
Content-Disposition: attachment; filename="README.txt"; size=1682;
|
||||||
|
creation-date="Mon, 24 Aug 2015 19:19:48 GMT";
|
||||||
|
modification-date="Mon, 24 Aug 2015 19:19:48 GMT"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
SW5idWNrZXQgWyFbQnVpbGQgU3RhdHVzXShodHRwczovL3RyYXZpcy1jaS5vcmcvamhpbGx5ZXJk
|
||||||
|
L2luYnVja2V0LnBuZz9icmFuY2g9bWFzdGVyKV0oaHR0cHM6Ly90cmF2aXMtY2kub3JnL2poaWxs
|
||||||
|
eWVyZC9pbmJ1Y2tldCkKPT09PT09PT0KCkluYnVja2V0IGlzIGFuIGVtYWlsIHRlc3Rpbmcgc2Vy
|
||||||
|
dmljZTsgaXQgd2lsbCBhY2NlcHQgbWVzc2FnZXMgZm9yIGFueSBlbWFpbAphZGRyZXNzIGFuZCBt
|
||||||
|
YWtlIHRoZW0gYXZhaWxhYmxlIHZpYSB3ZWIsIFJFU1QgYW5kIFBPUDMuICBPbmNlIGNvbXBpbGVk
|
||||||
|
LApJbmJ1Y2tldCBkb2VzIG5vdCBoYXZlIGFuIGV4dGVybmFsIGRlcGVuZGVuY2llcyAoSFRUUCwg
|
||||||
|
U01UUCwgUE9QMyBhbmQgc3RvcmFnZQphcmUgYWxsIGJ1aWx0IGluKS4KClJlYWQgbW9yZSBhdCB0
|
||||||
|
aGUgW0luYnVja2V0IHdlYnNpdGVdW0luYnVja2V0XQoKRGV2ZWxvcG1lbnQgU3RhdHVzCi0tLS0t
|
||||||
|
LS0tLS0tLS0tLS0tLQoKSW5idWNrZXQgaXMgY3VycmVudGx5IHByb2R1Y3Rpb24gcXVhbGl0eTog
|
||||||
|
aXQgaXMgYmVpbmcgdXNlZCBmb3IgcmVhbCB3b3JrLgoKUGxlYXNlIGNoZWNrIHRoZSBbaXNzdWVz
|
||||||
|
IGxpc3RdW0lzc3Vlc10KZm9yIG1vcmUgZGV0YWlscy4KCkJ1aWxkaW5nIGZyb20gU291cmNlCi0t
|
||||||
|
LS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKWW91IHdpbGwgbmVlZCBhIGZ1bmN0aW9uaW5nIFtHbyBp
|
||||||
|
bnN0YWxsYXRpb25dW0dvbGFuZ10gZm9yIHRoaXMgdG8gd29yay4KCkdyYWIgdGhlIEluYnVja2V0
|
||||||
|
IHNvdXJjZSBjb2RlIGFuZCBjb21waWxlIHRoZSBkYWVtb246CgogICAgZ28gZ2V0IC12IGdpdGh1
|
||||||
|
Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0CgpFZGl0IGV0Yy9pbmJ1Y2tldC5jb25mIGFuZCB0YWls
|
||||||
|
b3IgdG8geW91ciBlbnZpcm9ubWVudC4gIEl0IHNob3VsZCB3b3JrIG9uIG1vc3QKVW5peCBhbmQg
|
||||||
|
T1MgWCBtYWNoaW5lcyBhcyBpcy4gIExhdW5jaCB0aGUgZGFlbW9uOgoKICAgICRHT1BBVEgvYmlu
|
||||||
|
L2luYnVja2V0ICRHT1BBVEgvc3JjL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0L2V0Yy9p
|
||||||
|
bmJ1Y2tldC5jb25mCgpCeSBkZWZhdWx0IHRoZSBTTVRQIHNlcnZlciB3aWxsIGJlIGxpc3Rlbmlu
|
||||||
|
ZyBvbiBsb2NhbGhvc3QgcG9ydCAyNTAwIGFuZAp0aGUgd2ViIGludGVyZmFjZSB3aWxsIGJlIGF2
|
||||||
|
YWlsYWJsZSBhdCBbbG9jYWxob3N0OjkwMDBdKGh0dHA6Ly9sb2NhbGhvc3Q6OTAwMC8pLgoKVGhl
|
||||||
|
IEluYnVja2V0IHdlYnNpdGUgaGFzIGEgbW9yZSBjb21wbGV0ZSBndWlkZSB0bwpbaW5zdGFsbGlu
|
||||||
|
ZyBmcm9tIHNvdXJjZV1bRnJvbSBTb3VyY2VdCgpBYm91dAotLS0tLQoKSW5idWNrZXQgaXMgd3Jp
|
||||||
|
dHRlbiBpbiBbR29vZ2xlIEdvXVtHb2xhbmddLgoKSW5idWNrZXQgaXMgb3BlbiBzb3VyY2Ugc29m
|
||||||
|
dHdhcmUgcmVsZWFzZWQgdW5kZXIgdGhlIE1JVCBMaWNlbnNlLiAgVGhlIGxhdGVzdAp2ZXJzaW9u
|
||||||
|
IGNhbiBiZSBmb3VuZCBhdCBodHRwczovL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0Cgpb
|
||||||
|
SW5idWNrZXRdOiBodHRwOi8vd3d3LmluYnVja2V0Lm9yZy8KW0lzc3Vlc106IGh0dHBzOi8vZ2l0
|
||||||
|
aHViLmNvbS9qaGlsbHllcmQvaW5idWNrZXQvaXNzdWVzP3N0YXRlPW9wZW4KW0Zyb20gU291cmNl
|
||||||
|
XTogaHR0cDovL3d3dy5pbmJ1Y2tldC5vcmcvaW5zdGFsbGF0aW9uL2Zyb20tc291cmNlLmh0bWwK
|
||||||
|
W0dvbGFuZ106IGh0dHA6Ly9nb2xhbmcub3JnLwo=
|
||||||
|
|
||||||
|
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_--
|
||||||
61
etc/swaks-tests/run-tests.sh
Executable file
61
etc/swaks-tests/run-tests.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# run-tests.sh
|
||||||
|
# description: Generate test emails for Inbucket
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
[ $TRACE ] && set -x
|
||||||
|
|
||||||
|
# We need to be in swaks-tests directory
|
||||||
|
cmdpath="$(dirname "$0")"
|
||||||
|
if [ "$cmdpath" != "." ]; then
|
||||||
|
cd "$cmdpath"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
"")
|
||||||
|
to="swaks"
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
to="swaks"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
to="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export SWAKS_OPT_server="127.0.0.1:2500"
|
||||||
|
export SWAKS_OPT_to="$to@inbucket.local"
|
||||||
|
|
||||||
|
# Basic test
|
||||||
|
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
|
||||||
|
|
||||||
|
# Multi-recipient test
|
||||||
|
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
|
||||||
|
--body text.txt
|
||||||
|
|
||||||
|
# HTML test
|
||||||
|
swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
|
||||||
|
|
||||||
|
# Top level HTML test
|
||||||
|
swaks $* --h-Subject: "Swaks Top Level HTML" --data nonmime-html.raw
|
||||||
|
|
||||||
|
# Attachment test
|
||||||
|
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
|
||||||
|
--body text.txt
|
||||||
|
|
||||||
|
# Encoded subject line test
|
||||||
|
swaks $* --data utf8-subject.raw
|
||||||
|
|
||||||
|
# Gmail test
|
||||||
|
swaks $* --data gmail.raw
|
||||||
|
|
||||||
|
# Outlook test
|
||||||
|
swaks $* --data outlook.raw
|
||||||
|
|
||||||
|
# Non-mime responsive HTML test
|
||||||
|
swaks $* --data nonmime-html-responsive.raw
|
||||||
|
swaks $* --data nonmime-html-inlined.raw
|
||||||
|
|
||||||
|
# Incorrect charset, malformed final boundary
|
||||||
|
swaks $* --data mime-errors.raw
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
Date: %DATE%
|
Date: %DATE%
|
||||||
To: %TO_ADDRESS%
|
To: %TO_ADDRESS%,
|
||||||
From: %FROM_ADDRESS%
|
=?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
|
||||||
|
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?=
|
||||||
|
<fromuser@inbucket.org>
|
||||||
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||||
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||||
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Please see the Ubuntu installation guide on our website:
|
|
||||||
|
|
||||||
http://jhillyerd.github.com/inbucket/installation/ubuntu.html
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# inbucket - disposable email service
|
|
||||||
#
|
|
||||||
# Inbucket is an SMTP server with a web interface for testing application
|
|
||||||
# functionality
|
|
||||||
|
|
||||||
description "inbucket - disposable email service"
|
|
||||||
author "http://jhillyerd.github.com/inbucket"
|
|
||||||
|
|
||||||
start on (local-filesystems and net-device-up IFACE!=lo)
|
|
||||||
stop on runlevel [!2345]
|
|
||||||
|
|
||||||
env program=/opt/inbucket/inbucket
|
|
||||||
env config=/etc/opt/inbucket.conf
|
|
||||||
env logfile=/var/log/inbucket.log
|
|
||||||
env runas=inbucket
|
|
||||||
|
|
||||||
# Give SMTP connections time to drain
|
|
||||||
kill timeout 20
|
|
||||||
|
|
||||||
pre-start script
|
|
||||||
[ -x $program ]
|
|
||||||
[ -r $config ]
|
|
||||||
touch $logfile
|
|
||||||
chown $runas: $logfile
|
|
||||||
# Allow bind to ports under 1024
|
|
||||||
setcap 'cap_net_bind_service=+ep' $program
|
|
||||||
end script
|
|
||||||
|
|
||||||
exec start-stop-daemon --start --chuid $runas --exec $program -- -logfile $logfile $config
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/var/log/inbucket.log {
|
|
||||||
missingok
|
|
||||||
notifempty
|
|
||||||
create 0644 inbucket inbucket
|
|
||||||
postrotate
|
|
||||||
[ -x /sbin/reload ] && /sbin/reload inbucket >/dev/null 2>&1 || true
|
|
||||||
endscript
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# inbucket.conf
|
|
||||||
# Sample inbucket configuration
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used by directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=/opt/inbucket
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=25
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
#domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=110
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=80
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=integral
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can
|
|
||||||
# be moved out of installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s/themes/greeting.html
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=/var/opt/inbucket
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=240
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# win-sample.conf
|
|
||||||
# Sample inbucket configuration for Windows
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[DEFAULT]
|
|
||||||
|
|
||||||
# Not used by directly, but is typically referenced below in %()s format.
|
|
||||||
install.dir=.
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[logging]
|
|
||||||
|
|
||||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
|
||||||
level=INFO
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[smtp]
|
|
||||||
|
|
||||||
# IPv4 address to listen for SMTP connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for SMTP connections on.
|
|
||||||
ip4.port=2500
|
|
||||||
|
|
||||||
# used in SMTP greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# optional: mail sent to accounts at this domain will not be stored,
|
|
||||||
# for mixed use (content and load testing)
|
|
||||||
#domain.nostore=bitbucket.local
|
|
||||||
|
|
||||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
|
||||||
# RFC recommends this be at least 100.
|
|
||||||
max.recipients=100
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
|
||||||
max.idle.seconds=300
|
|
||||||
|
|
||||||
# Maximum allowable size of message body in bytes (including attachments)
|
|
||||||
max.message.bytes=2048000
|
|
||||||
|
|
||||||
# Should we place messages into the datastore, or just throw them away
|
|
||||||
# (for load testing): true or false
|
|
||||||
store.messages=true
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[pop3]
|
|
||||||
|
|
||||||
# IPv4 address to listen for POP3 connections on.
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to listen for POP3 connections on.
|
|
||||||
ip4.port=1100
|
|
||||||
|
|
||||||
# used in POP3 greeting
|
|
||||||
domain=inbucket.local
|
|
||||||
|
|
||||||
# How long we allow a network connection to be idle before hanging up on the
|
|
||||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
|
||||||
max.idle.seconds=600
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[web]
|
|
||||||
|
|
||||||
# IPv4 address to serve HTTP web interface on
|
|
||||||
ip4.address=0.0.0.0
|
|
||||||
|
|
||||||
# IPv4 port to serve HTTP web interface on
|
|
||||||
ip4.port=9000
|
|
||||||
|
|
||||||
# Name of web theme to use
|
|
||||||
theme=integral
|
|
||||||
|
|
||||||
# Path to the selected themes template files
|
|
||||||
template.dir=%(install.dir)s\themes\%(theme)s\templates
|
|
||||||
|
|
||||||
# Should we cache parsed templates (set to false during theme dev)
|
|
||||||
template.cache=true
|
|
||||||
|
|
||||||
# Path to the selected themes public (static) files
|
|
||||||
public.dir=%(install.dir)s\themes\%(theme)s\public
|
|
||||||
|
|
||||||
# Path to the greeting HTML displayed on front page, can
|
|
||||||
# be moved out of installation dir for customization
|
|
||||||
greeting.file=%(install.dir)s\themes\greeting.html
|
|
||||||
|
|
||||||
#############################################################################
|
|
||||||
[datastore]
|
|
||||||
|
|
||||||
# Path to the datastore, mail will be written into subdirectories
|
|
||||||
path=.\inbucket-data
|
|
||||||
|
|
||||||
# How many minutes after receipt should a message be stored until it's
|
|
||||||
# automatically purged. To retain messages until manually deleted, set this
|
|
||||||
# to 0
|
|
||||||
retention.minutes=240
|
|
||||||
|
|
||||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
|
||||||
# This should help reduce disk I/O when there are a large number of messages
|
|
||||||
# to purge.
|
|
||||||
retention.sleep.millis=100
|
|
||||||
25
go.mod
Normal file
25
go.mod
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module github.com/inbucket/inbucket
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
|
||||||
|
github.com/google/subcommands v1.2.0
|
||||||
|
github.com/gorilla/css v1.0.0
|
||||||
|
github.com/gorilla/mux v1.8.0
|
||||||
|
github.com/gorilla/websocket v1.4.2
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
|
||||||
|
github.com/jhillyerd/enmime v0.8.1
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.4
|
||||||
|
github.com/olekukonko/tablewriter v0.0.4 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rs/zerolog v1.20.0
|
||||||
|
github.com/stretchr/testify v1.6.1
|
||||||
|
golang.org/x/net v0.0.0-20200923182212-328152dc79b1
|
||||||
|
golang.org/x/text v0.3.3 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
go 1.13
|
||||||
89
go.sum
Normal file
89
go.sum
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
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/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
|
||||||
|
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
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/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
|
||||||
|
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||||
|
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
|
||||||
|
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
|
||||||
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
|
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
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.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/jhillyerd/enmime v0.8.1 h1:Kz4xj3sJJ4Ju8e+w/7v9H4Matv5ijPgv7UkhPf+C15I=
|
||||||
|
github.com/jhillyerd/enmime v0.8.1/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
|
github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
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/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
|
github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs=
|
||||||
|
github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
|
||||||
|
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
|
||||||
|
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||||
|
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.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
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-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200923182212-328152dc79b1 h1:Iu68XRPd67wN4aRGGWwwq6bZo/25jR6uu52l/j2KkUE=
|
||||||
|
golang.org/x/net v0.0.0-20200923182212-328152dc79b1/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -1 +0,0 @@
|
|||||||
inbucket.exe etc\win-sample.conf
|
|
||||||
182
inbucket.go
182
inbucket.go
@@ -1,182 +0,0 @@
|
|||||||
/*
|
|
||||||
This is the inbucket daemon launcher
|
|
||||||
*/
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"expvar"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
"github.com/jhillyerd/inbucket/pop3d"
|
|
||||||
"github.com/jhillyerd/inbucket/smtpd"
|
|
||||||
"github.com/jhillyerd/inbucket/web"
|
|
||||||
golog "log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Command line flags
|
|
||||||
var help = flag.Bool("help", false, "Displays this help")
|
|
||||||
var pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
|
|
||||||
var logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
|
|
||||||
|
|
||||||
// startTime is used to calculate uptime of Inbucket
|
|
||||||
var startTime = time.Now()
|
|
||||||
|
|
||||||
// The file we send log output to, will be nil for stderr or stdout
|
|
||||||
var logf *os.File
|
|
||||||
|
|
||||||
var smtpServer *smtpd.Server
|
|
||||||
var pop3Server *pop3d.Server
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
flag.Parse()
|
|
||||||
if *help {
|
|
||||||
flag.Usage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load & Parse config
|
|
||||||
if flag.NArg() != 1 {
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
err := config.LoadConfig(flag.Arg(0))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to parse config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup signal handler
|
|
||||||
sigChan := make(chan os.Signal)
|
|
||||||
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM)
|
|
||||||
go signalProcessor(sigChan)
|
|
||||||
|
|
||||||
// Configure logging, close std* fds
|
|
||||||
level, _ := config.Config.String("logging", "level")
|
|
||||||
log.SetLogLevel(level)
|
|
||||||
|
|
||||||
if *logfile != "stderr" {
|
|
||||||
// stderr is the go logging default
|
|
||||||
if *logfile == "stdout" {
|
|
||||||
// set to stdout
|
|
||||||
golog.SetOutput(os.Stdout)
|
|
||||||
} else {
|
|
||||||
err = openLogFile()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%v", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer closeLogFile()
|
|
||||||
|
|
||||||
// close std* streams
|
|
||||||
os.Stdout.Close()
|
|
||||||
os.Stderr.Close() // Warning: this will hide panic() output
|
|
||||||
os.Stdin.Close()
|
|
||||||
os.Stdout = logf
|
|
||||||
os.Stderr = logf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write pidfile if requested
|
|
||||||
// TODO: Probably supposed to remove pidfile during shutdown
|
|
||||||
if *pidfile != "none" {
|
|
||||||
pidf, err := os.Create(*pidfile)
|
|
||||||
if err != nil {
|
|
||||||
log.LogError("Failed to create %v: %v", *pidfile, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pidf.Close()
|
|
||||||
fmt.Fprintf(pidf, "%v\n", os.Getpid())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grab our datastore
|
|
||||||
ds := smtpd.DefaultFileDataStore()
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
go web.Start()
|
|
||||||
|
|
||||||
// Start POP3 server
|
|
||||||
pop3Server = pop3d.New()
|
|
||||||
go pop3Server.Start()
|
|
||||||
|
|
||||||
// Startup SMTP server, block until it exits
|
|
||||||
smtpServer = smtpd.NewSmtpServer(config.GetSmtpConfig(), ds)
|
|
||||||
smtpServer.Start()
|
|
||||||
|
|
||||||
// Wait for active connections to finish
|
|
||||||
smtpServer.Drain()
|
|
||||||
pop3Server.Drain()
|
|
||||||
}
|
|
||||||
|
|
||||||
// openLogFile creates or appends to the logfile passed on commandline
|
|
||||||
func openLogFile() error {
|
|
||||||
// use specified log file
|
|
||||||
var err error
|
|
||||||
logf, err = os.OpenFile(*logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to create %v: %v\n", *logfile, err)
|
|
||||||
}
|
|
||||||
golog.SetOutput(logf)
|
|
||||||
log.LogTrace("Opened new logfile")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeLogFile closes the current logfile
|
|
||||||
func closeLogFile() error {
|
|
||||||
log.LogTrace("Closing logfile")
|
|
||||||
return logf.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// signalProcessor is a goroutine that handles OS signals
|
|
||||||
func signalProcessor(c <-chan os.Signal) {
|
|
||||||
for {
|
|
||||||
sig := <-c
|
|
||||||
switch sig {
|
|
||||||
case syscall.SIGHUP:
|
|
||||||
// Rotate logs if configured
|
|
||||||
if logf != nil {
|
|
||||||
log.LogInfo("Recieved SIGHUP, cycling logfile")
|
|
||||||
closeLogFile()
|
|
||||||
openLogFile()
|
|
||||||
} else {
|
|
||||||
log.LogInfo("Ignoring SIGHUP, logfile not configured")
|
|
||||||
}
|
|
||||||
case syscall.SIGTERM:
|
|
||||||
// Initiate shutdown
|
|
||||||
log.LogInfo("Received SIGTERM, shutting down")
|
|
||||||
go timedExit()
|
|
||||||
web.Stop()
|
|
||||||
if smtpServer != nil {
|
|
||||||
smtpServer.Stop()
|
|
||||||
} else {
|
|
||||||
log.LogError("smtpServer was nil during shutdown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// timedExit is called as a goroutine during shutdown, it will force an exit after 15 seconds
|
|
||||||
func timedExit() {
|
|
||||||
time.Sleep(15 * time.Second)
|
|
||||||
log.LogError("Inbucket clean shutdown timed out, forcing exit")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
expvar.Publish("uptime", expvar.Func(uptime))
|
|
||||||
}
|
|
||||||
|
|
||||||
// uptime() is published as an expvar
|
|
||||||
func uptime() interface{} {
|
|
||||||
return time.Since(startTime) / time.Second
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package log
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogLevel int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ERROR LogLevel = iota
|
|
||||||
WARN
|
|
||||||
INFO
|
|
||||||
TRACE
|
|
||||||
)
|
|
||||||
|
|
||||||
var MaxLogLevel LogLevel = TRACE
|
|
||||||
|
|
||||||
// SetLogLevel sets MaxLogLevel based on the provided string
|
|
||||||
func SetLogLevel(level string) (ok bool) {
|
|
||||||
switch strings.ToUpper(level) {
|
|
||||||
case "ERROR":
|
|
||||||
MaxLogLevel = ERROR
|
|
||||||
case "WARN":
|
|
||||||
MaxLogLevel = WARN
|
|
||||||
case "INFO":
|
|
||||||
MaxLogLevel = INFO
|
|
||||||
case "TRACE":
|
|
||||||
MaxLogLevel = TRACE
|
|
||||||
default:
|
|
||||||
LogError("Unknown log level requested: %v", level)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error logs a message to the 'standard' Logger (always)
|
|
||||||
func LogError(msg string, args ...interface{}) {
|
|
||||||
msg = "[ERROR] " + msg
|
|
||||||
log.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn logs a message to the 'standard' Logger if MaxLogLevel is >= WARN
|
|
||||||
func LogWarn(msg string, args ...interface{}) {
|
|
||||||
if MaxLogLevel >= WARN {
|
|
||||||
msg = "[WARN ] " + msg
|
|
||||||
log.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info logs a message to the 'standard' Logger if MaxLogLevel is >= INFO
|
|
||||||
func LogInfo(msg string, args ...interface{}) {
|
|
||||||
if MaxLogLevel >= INFO {
|
|
||||||
msg = "[INFO ] " + msg
|
|
||||||
log.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trace logs a message to the 'standard' Logger if MaxLogLevel is >= TRACE
|
|
||||||
func LogTrace(msg string, args ...interface{}) {
|
|
||||||
if MaxLogLevel >= TRACE {
|
|
||||||
msg = "[TRACE] " + msg
|
|
||||||
log.Printf(msg, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
135
pkg/config/config.go
Normal file
135
pkg/config/config.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
prefix = "inbucket"
|
||||||
|
tableFormat = `Inbucket is configured via the environment. The following environment variables
|
||||||
|
can be used:
|
||||||
|
|
||||||
|
KEY DEFAULT DESCRIPTION
|
||||||
|
{{range .}}{{usage_key .}} {{usage_default .}} {{usage_description .}}
|
||||||
|
{{end}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Version of this build, set by main
|
||||||
|
Version = ""
|
||||||
|
|
||||||
|
// BuildDate for this build, set by main
|
||||||
|
BuildDate = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// mbNaming represents a mailbox naming strategy.
|
||||||
|
type mbNaming int
|
||||||
|
|
||||||
|
// Mailbox naming strategies.
|
||||||
|
const (
|
||||||
|
UnknownNaming mbNaming = iota
|
||||||
|
LocalNaming
|
||||||
|
FullNaming
|
||||||
|
DomainNaming
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decode a naming strategy from string.
|
||||||
|
func (n *mbNaming) Decode(v string) error {
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "local":
|
||||||
|
*n = LocalNaming
|
||||||
|
case "full":
|
||||||
|
*n = FullNaming
|
||||||
|
case "domain":
|
||||||
|
*n = DomainNaming
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root contains global configuration, and structs with for specific sub-systems.
|
||||||
|
type Root struct {
|
||||||
|
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||||
|
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
|
||||||
|
SMTP SMTP
|
||||||
|
POP3 POP3
|
||||||
|
Web Web
|
||||||
|
Storage Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP contains the SMTP server configuration.
|
||||||
|
type SMTP struct {
|
||||||
|
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
||||||
|
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
|
||||||
|
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
|
||||||
|
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
|
||||||
|
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
|
||||||
|
AcceptDomains []string `desc:"Domains to accept mail for"`
|
||||||
|
RejectDomains []string `desc:"Domains to reject mail for"`
|
||||||
|
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
|
||||||
|
StoreDomains []string `desc:"Domains to store mail for"`
|
||||||
|
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||||
|
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||||
|
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
||||||
|
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||||
|
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||||
|
Debug bool `ignored:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POP3 contains the POP3 server configuration.
|
||||||
|
type POP3 struct {
|
||||||
|
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
|
||||||
|
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
|
||||||
|
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
|
||||||
|
Debug bool `ignored:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web contains the HTTP server configuration.
|
||||||
|
type Web struct {
|
||||||
|
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||||
|
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
|
||||||
|
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
|
||||||
|
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||||
|
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||||
|
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||||
|
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage contains the mail store configuration.
|
||||||
|
type Storage struct {
|
||||||
|
Type string `required:"true" default:"memory" desc:"Storage impl: file or memory"`
|
||||||
|
Params map[string]string `desc:"Storage impl parameters, see docs."`
|
||||||
|
RetentionPeriod time.Duration `required:"true" default:"24h" desc:"Duration to retain messages"`
|
||||||
|
RetentionSleep time.Duration `required:"true" default:"50ms" desc:"Duration to sleep between mailboxes"`
|
||||||
|
MailboxMsgCap int `required:"true" default:"500" desc:"Maximum messages per mailbox"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process loads and parses configuration from the environment.
|
||||||
|
func Process() (*Root, error) {
|
||||||
|
c := &Root{}
|
||||||
|
err := envconfig.Process(prefix, c)
|
||||||
|
c.LogLevel = strings.ToLower(c.LogLevel)
|
||||||
|
stringutil.SliceToLower(c.SMTP.AcceptDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.RejectDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.StoreDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.DiscardDomains)
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage prints out the envconfig usage to Stderr.
|
||||||
|
func Usage() {
|
||||||
|
tabs := tabwriter.NewWriter(os.Stderr, 1, 0, 4, ' ', 0)
|
||||||
|
if err := envconfig.Usagef(prefix, &Root{}, tabs, tableFormat); err != nil {
|
||||||
|
log.Fatalf("Unable to parse env config: %v", err)
|
||||||
|
}
|
||||||
|
tabs.Flush()
|
||||||
|
}
|
||||||
174
pkg/message/manager.go
Normal file
174
pkg/message/manager.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager is the interface controllers use to interact with messages.
|
||||||
|
type Manager interface {
|
||||||
|
Deliver(
|
||||||
|
to *policy.Recipient,
|
||||||
|
from string,
|
||||||
|
recipients []*policy.Recipient,
|
||||||
|
prefix string,
|
||||||
|
content []byte,
|
||||||
|
) (id string, err error)
|
||||||
|
GetMetadata(mailbox string) ([]*Metadata, error)
|
||||||
|
GetMessage(mailbox, id string) (*Message, error)
|
||||||
|
MarkSeen(mailbox, id string) error
|
||||||
|
PurgeMessages(mailbox string) error
|
||||||
|
RemoveMessage(mailbox, id string) error
|
||||||
|
SourceReader(mailbox, id string) (io.ReadCloser, error)
|
||||||
|
MailboxForAddress(address string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreManager is a message Manager backed by the storage.Store.
|
||||||
|
type StoreManager struct {
|
||||||
|
AddrPolicy *policy.Addressing
|
||||||
|
Store storage.Store
|
||||||
|
Hub *msghub.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver submits a new message to the store.
|
||||||
|
func (s *StoreManager) Deliver(
|
||||||
|
to *policy.Recipient,
|
||||||
|
from string,
|
||||||
|
recipients []*policy.Recipient,
|
||||||
|
prefix string,
|
||||||
|
source []byte,
|
||||||
|
) (string, error) {
|
||||||
|
// TODO enmime is too heavy for this step, only need header.
|
||||||
|
// Go's header parsing isn't good enough, so this is blocked on enmime issue #64.
|
||||||
|
env, err := enmime.ReadEnvelope(bytes.NewReader(source))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fromaddr, err := env.AddressList("From")
|
||||||
|
if err != nil || len(fromaddr) == 0 {
|
||||||
|
fromaddr = []*mail.Address{{Address: from}}
|
||||||
|
}
|
||||||
|
toaddr, err := env.AddressList("To")
|
||||||
|
if err != nil {
|
||||||
|
toaddr = make([]*mail.Address, len(recipients))
|
||||||
|
for i, torecip := range recipients {
|
||||||
|
toaddr[i] = &torecip.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
|
||||||
|
delivery := &Delivery{
|
||||||
|
Meta: Metadata{
|
||||||
|
Mailbox: to.Mailbox,
|
||||||
|
From: fromaddr[0],
|
||||||
|
To: toaddr,
|
||||||
|
Date: time.Now(),
|
||||||
|
Subject: env.GetHeader("Subject"),
|
||||||
|
},
|
||||||
|
Reader: io.MultiReader(strings.NewReader(prefix), bytes.NewReader(source)),
|
||||||
|
}
|
||||||
|
id, err := s.Store.AddMessage(delivery)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.Hub != nil {
|
||||||
|
// Broadcast message information.
|
||||||
|
broadcast := msghub.Message{
|
||||||
|
Mailbox: to.Mailbox,
|
||||||
|
ID: id,
|
||||||
|
From: stringutil.StringAddress(delivery.From()),
|
||||||
|
To: stringutil.StringAddressList(delivery.To()),
|
||||||
|
Subject: delivery.Subject(),
|
||||||
|
Date: delivery.Date(),
|
||||||
|
Size: delivery.Size(),
|
||||||
|
}
|
||||||
|
s.Hub.Dispatch(broadcast)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata returns a slice of metadata for the specified mailbox.
|
||||||
|
func (s *StoreManager) GetMetadata(mailbox string) ([]*Metadata, error) {
|
||||||
|
messages, err := s.Store.GetMessages(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
metas := make([]*Metadata, len(messages))
|
||||||
|
for i, sm := range messages {
|
||||||
|
metas[i] = makeMetadata(sm)
|
||||||
|
}
|
||||||
|
return metas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns the specified message.
|
||||||
|
func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
|
||||||
|
sm, err := s.Store.GetMessage(mailbox, id)
|
||||||
|
if err != nil || sm == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := sm.Source()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env, err := enmime.ReadEnvelope(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
header := makeMetadata(sm)
|
||||||
|
return &Message{Metadata: *header, env: env}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen marks the message as having been read.
|
||||||
|
func (s *StoreManager) MarkSeen(mailbox, id string) error {
|
||||||
|
log.Debug().Str("module", "manager").Str("mailbox", mailbox).Str("id", id).
|
||||||
|
Msg("Marking as seen")
|
||||||
|
return s.Store.MarkSeen(mailbox, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessages removes all messages from the specified mailbox.
|
||||||
|
func (s *StoreManager) PurgeMessages(mailbox string) error {
|
||||||
|
return s.Store.PurgeMessages(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMessage deletes the specified message.
|
||||||
|
func (s *StoreManager) RemoveMessage(mailbox, id string) error {
|
||||||
|
return s.Store.RemoveMessage(mailbox, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceReader allows the stored message source to be read.
|
||||||
|
func (s *StoreManager) SourceReader(mailbox, id string) (io.ReadCloser, error) {
|
||||||
|
sm, err := s.Store.GetMessage(mailbox, id)
|
||||||
|
if err != nil || sm == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sm.Source()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxForAddress parses an email address to return the canonical mailbox name.
|
||||||
|
func (s *StoreManager) MailboxForAddress(mailbox string) (string, error) {
|
||||||
|
return s.AddrPolicy.ExtractMailbox(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeMetadata populates Metadata from a storage.Message.
|
||||||
|
func makeMetadata(m storage.Message) *Metadata {
|
||||||
|
return &Metadata{
|
||||||
|
Mailbox: m.Mailbox(),
|
||||||
|
ID: m.ID(),
|
||||||
|
From: m.From(),
|
||||||
|
To: m.To(),
|
||||||
|
Date: m.Date(),
|
||||||
|
Subject: m.Subject(),
|
||||||
|
Size: m.Size(),
|
||||||
|
Seen: m.Seen(),
|
||||||
|
}
|
||||||
|
}
|
||||||
117
pkg/message/message.go
Normal file
117
pkg/message/message.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Package message contains message handling logic.
|
||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata holds information about a message, but not the content.
|
||||||
|
type Metadata struct {
|
||||||
|
Mailbox string
|
||||||
|
ID string
|
||||||
|
From *mail.Address
|
||||||
|
To []*mail.Address
|
||||||
|
Date time.Time
|
||||||
|
Subject string
|
||||||
|
Size int64
|
||||||
|
Seen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message holds both the metadata and content of a message.
|
||||||
|
type Message struct {
|
||||||
|
Metadata
|
||||||
|
env *enmime.Envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a new Message
|
||||||
|
func New(m Metadata, e *enmime.Envelope) *Message {
|
||||||
|
return &Message{
|
||||||
|
Metadata: m,
|
||||||
|
env: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments returns the MIME attachments for the message.
|
||||||
|
func (m *Message) Attachments() []*enmime.Part {
|
||||||
|
return m.env.Attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header returns the header map for this message.
|
||||||
|
func (m *Message) Header() textproto.MIMEHeader {
|
||||||
|
return m.env.Root.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML returns the HTML body of the message.
|
||||||
|
func (m *Message) HTML() string {
|
||||||
|
return m.env.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIMEErrors returns MIME parsing errors and warnings.
|
||||||
|
func (m *Message) MIMEErrors() []*enmime.Error {
|
||||||
|
return m.env.Errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text returns the plain text body of the message.
|
||||||
|
func (m *Message) Text() string {
|
||||||
|
return m.env.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery is used to add a message to storage.
|
||||||
|
type Delivery struct {
|
||||||
|
Meta Metadata
|
||||||
|
Reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Message = &Delivery{}
|
||||||
|
|
||||||
|
// Mailbox getter.
|
||||||
|
func (d *Delivery) Mailbox() string {
|
||||||
|
return d.Meta.Mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID getter.
|
||||||
|
func (d *Delivery) ID() string {
|
||||||
|
return d.Meta.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// From getter.
|
||||||
|
func (d *Delivery) From() *mail.Address {
|
||||||
|
return d.Meta.From
|
||||||
|
}
|
||||||
|
|
||||||
|
// To getter.
|
||||||
|
func (d *Delivery) To() []*mail.Address {
|
||||||
|
return d.Meta.To
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date getter.
|
||||||
|
func (d *Delivery) Date() time.Time {
|
||||||
|
return d.Meta.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject getter.
|
||||||
|
func (d *Delivery) Subject() string {
|
||||||
|
return d.Meta.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size getter.
|
||||||
|
func (d *Delivery) Size() int64 {
|
||||||
|
return d.Meta.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source contains the raw content of the message.
|
||||||
|
func (d *Delivery) Source() (io.ReadCloser, error) {
|
||||||
|
return ioutil.NopCloser(d.Reader), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seen getter.
|
||||||
|
func (d *Delivery) Seen() bool {
|
||||||
|
return d.Meta.Seen
|
||||||
|
}
|
||||||
63
pkg/metric/metric.go
Normal file
63
pkg/metric/metric.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package metric
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"expvar"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TickerFunc is the function signature accepted by AddTickerFunc, will be called once per minute.
|
||||||
|
type TickerFunc func()
|
||||||
|
|
||||||
|
var tickerFuncChan = make(chan TickerFunc)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go metricsTicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTickerFunc adds a new function callback to the list of metrics TickerFuncs that get
|
||||||
|
// called each minute.
|
||||||
|
func AddTickerFunc(f TickerFunc) {
|
||||||
|
tickerFuncChan <- f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push adds the metric to the end of the list and returns a comma separated string of the
|
||||||
|
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
|
||||||
|
// tracks deltas between these values - there is nothing to compare the first value against.
|
||||||
|
func Push(history *list.List, ev expvar.Var) string {
|
||||||
|
history.PushBack(ev.String())
|
||||||
|
if history.Len() > 61 {
|
||||||
|
history.Remove(history.Front())
|
||||||
|
}
|
||||||
|
return joinStringList(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsTicker calls the current list of TickerFuncs once per minute.
|
||||||
|
func metricsTicker() {
|
||||||
|
funcs := make([]TickerFunc, 0)
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
for _, f := range funcs {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
case f := <-tickerFuncChan:
|
||||||
|
funcs = append(funcs, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinStringList joins a List containing strings by commas.
|
||||||
|
func joinStringList(listOfStrings *list.List) string {
|
||||||
|
if listOfStrings.Len() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := make([]string, 0, listOfStrings.Len())
|
||||||
|
for e := listOfStrings.Front(); e != nil; e = e.Next() {
|
||||||
|
s = append(s, e.Value.(string))
|
||||||
|
}
|
||||||
|
return strings.Join(s, ",")
|
||||||
|
}
|
||||||
110
pkg/msghub/hub.go
Normal file
110
pkg/msghub/hub.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package msghub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/ring"
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Length of msghub operation queue
|
||||||
|
const opChanLen = 100
|
||||||
|
|
||||||
|
// Message contains the basic header data for a message
|
||||||
|
type Message struct {
|
||||||
|
Mailbox string
|
||||||
|
ID string
|
||||||
|
From string
|
||||||
|
To []string
|
||||||
|
Subject string
|
||||||
|
Date time.Time
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener receives the contents of the history buffer, followed by new messages
|
||||||
|
type Listener interface {
|
||||||
|
Receive(msg Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub relays messages on to its listeners
|
||||||
|
type Hub struct {
|
||||||
|
// history buffer, points next Message to write. Proceeding non-nil entry is oldest Message
|
||||||
|
history *ring.Ring
|
||||||
|
listeners map[Listener]struct{} // listeners interested in new messages
|
||||||
|
opChan chan func(h *Hub) // operations queued for this actor
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a new Hub which will cache historyLen messages in memory for playback to future
|
||||||
|
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
|
||||||
|
// context is canceled.
|
||||||
|
func New(ctx context.Context, historyLen int) *Hub {
|
||||||
|
h := &Hub{
|
||||||
|
history: ring.New(historyLen),
|
||||||
|
listeners: make(map[Listener]struct{}),
|
||||||
|
opChan: make(chan func(h *Hub), opChanLen),
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Shutdown
|
||||||
|
close(h.opChan)
|
||||||
|
return
|
||||||
|
case op := <-h.opChan:
|
||||||
|
op(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
|
||||||
|
// history buffer and then relayed to all registered listeners.
|
||||||
|
func (hub *Hub) Dispatch(msg Message) {
|
||||||
|
hub.opChan <- func(h *Hub) {
|
||||||
|
if h.history != nil {
|
||||||
|
// Add to history buffer
|
||||||
|
h.history.Value = msg
|
||||||
|
h.history = h.history.Next()
|
||||||
|
// Deliver message to all listeners, removing listeners if they return an error
|
||||||
|
for l := range h.listeners {
|
||||||
|
if err := l.Receive(msg); err != nil {
|
||||||
|
delete(h.listeners, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddListener registers a listener to receive broadcasted messages.
|
||||||
|
func (hub *Hub) AddListener(l Listener) {
|
||||||
|
hub.opChan <- func(h *Hub) {
|
||||||
|
// Playback log
|
||||||
|
h.history.Do(func(v interface{}) {
|
||||||
|
if v != nil {
|
||||||
|
l.Receive(v.(Message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add to listeners
|
||||||
|
h.listeners[l] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveListener deletes a listener registration, it will cease to receive messages.
|
||||||
|
func (hub *Hub) RemoveListener(l Listener) {
|
||||||
|
hub.opChan <- func(h *Hub) {
|
||||||
|
delete(h.listeners, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync blocks until the msghub has processed its queue up to this point, useful
|
||||||
|
// for unit tests.
|
||||||
|
func (hub *Hub) Sync() {
|
||||||
|
done := make(chan struct{})
|
||||||
|
hub.opChan <- func(h *Hub) {
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
255
pkg/msghub/hub_test.go
Normal file
255
pkg/msghub/hub_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package msghub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testListener implements the Listener interface, mock for unit tests
|
||||||
|
type testListener struct {
|
||||||
|
messages []*Message // received messages
|
||||||
|
wantMessages int // how many messages this listener wants to receive
|
||||||
|
errorAfter int // when != 0, messages until Receive() begins returning error
|
||||||
|
|
||||||
|
done chan struct{} // closed once we have received wantMessages
|
||||||
|
overflow chan struct{} // closed if we receive wantMessages+1
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestListener(want int) *testListener {
|
||||||
|
l := &testListener{
|
||||||
|
messages: make([]*Message, 0, want*2),
|
||||||
|
wantMessages: want,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
overflow: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if want == 0 {
|
||||||
|
close(l.done)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
|
||||||
|
// if instructed
|
||||||
|
func (l *testListener) Receive(msg Message) error {
|
||||||
|
l.messages = append(l.messages, &msg)
|
||||||
|
if len(l.messages) == l.wantMessages {
|
||||||
|
close(l.done)
|
||||||
|
}
|
||||||
|
if len(l.messages) == l.wantMessages+1 {
|
||||||
|
close(l.overflow)
|
||||||
|
}
|
||||||
|
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
|
||||||
|
return fmt.Errorf("Too many messages")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String formats the got vs wanted message counts
|
||||||
|
func (l *testListener) String() string {
|
||||||
|
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubNew(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
if hub == nil {
|
||||||
|
t.Fatal("New() == nil, expected a new Hub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubZeroLen(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 0)
|
||||||
|
m := Message{}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
hub.Dispatch(m)
|
||||||
|
}
|
||||||
|
// Just making sure Hub doesn't panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubZeroListeners(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
m := Message{}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
hub.Dispatch(m)
|
||||||
|
}
|
||||||
|
// Just making sure Hub doesn't panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubOneListener(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
m := Message{}
|
||||||
|
l := newTestListener(1)
|
||||||
|
|
||||||
|
hub.AddListener(l)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
|
||||||
|
// Wait for messages
|
||||||
|
select {
|
||||||
|
case <-l.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Error("Timeout:", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubRemoveListener(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
m := Message{}
|
||||||
|
l := newTestListener(1)
|
||||||
|
|
||||||
|
hub.AddListener(l)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.RemoveListener(l)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.Sync()
|
||||||
|
|
||||||
|
// Wait for messages
|
||||||
|
select {
|
||||||
|
case <-l.overflow:
|
||||||
|
t.Error(l)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// Expected result, no overflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubRemoveListenerOnError(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
m := Message{}
|
||||||
|
|
||||||
|
// error after 1 means listener should receive 2 messages before being removed
|
||||||
|
l := newTestListener(2)
|
||||||
|
l.errorAfter = 1
|
||||||
|
|
||||||
|
hub.AddListener(l)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.Sync()
|
||||||
|
|
||||||
|
// Wait for messages
|
||||||
|
select {
|
||||||
|
case <-l.overflow:
|
||||||
|
t.Error(l)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// Expected result, no overflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubHistoryReplay(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 100)
|
||||||
|
l1 := newTestListener(3)
|
||||||
|
hub.AddListener(l1)
|
||||||
|
|
||||||
|
// Broadcast 3 messages with no listeners
|
||||||
|
msgs := make([]Message, 3)
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
msgs[i] = Message{
|
||||||
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
|
}
|
||||||
|
hub.Dispatch(msgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for messages (live)
|
||||||
|
select {
|
||||||
|
case <-l1.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new listener
|
||||||
|
l2 := newTestListener(3)
|
||||||
|
hub.AddListener(l2)
|
||||||
|
|
||||||
|
// Wait for messages (history)
|
||||||
|
select {
|
||||||
|
case <-l2.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
got := l2.messages[i].Subject
|
||||||
|
want := msgs[i].Subject
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubHistoryReplayWrap(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
l1 := newTestListener(20)
|
||||||
|
hub.AddListener(l1)
|
||||||
|
|
||||||
|
// Broadcast more messages than the hub can hold
|
||||||
|
msgs := make([]Message, 20)
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
msgs[i] = Message{
|
||||||
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
|
}
|
||||||
|
hub.Dispatch(msgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for messages (live)
|
||||||
|
select {
|
||||||
|
case <-l1.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new listener
|
||||||
|
l2 := newTestListener(5)
|
||||||
|
hub.AddListener(l2)
|
||||||
|
|
||||||
|
// Wait for messages (history)
|
||||||
|
select {
|
||||||
|
case <-l2.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
got := l2.messages[i].Subject
|
||||||
|
want := msgs[i+15].Subject
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubContextCancel(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
hub := New(ctx, 5)
|
||||||
|
m := Message{}
|
||||||
|
l := newTestListener(1)
|
||||||
|
|
||||||
|
hub.AddListener(l)
|
||||||
|
hub.Dispatch(m)
|
||||||
|
hub.Sync()
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Wait for messages
|
||||||
|
select {
|
||||||
|
case <-l.overflow:
|
||||||
|
t.Error(l)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// Expected result, no overflow
|
||||||
|
}
|
||||||
|
}
|
||||||
315
pkg/policy/address.go
Normal file
315
pkg/policy/address.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addressing handles email address policy.
|
||||||
|
type Addressing struct {
|
||||||
|
Config *config.Root
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractMailbox extracts the mailbox name from a partial email address.
|
||||||
|
func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
||||||
|
local, domain, err := parseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
local, err = parseMailboxName(local)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if a.Config.MailboxNaming == config.LocalNaming {
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if a.Config.MailboxNaming == config.DomainNaming {
|
||||||
|
// If no domain is specified, assume this is being
|
||||||
|
// used for mailbox lookup via the API.
|
||||||
|
if domain == "" {
|
||||||
|
if ValidateDomainPart(local) == false {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", local, address)
|
||||||
|
}
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if ValidateDomainPart(domain) == false {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
if a.Config.MailboxNaming != config.FullNaming {
|
||||||
|
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if !ValidateDomainPart(domain) {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||||
|
}
|
||||||
|
return local + "@" + domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecipient parses an address into a Recipient.
|
||||||
|
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
||||||
|
local, domain, err := ParseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mailbox, err := a.ExtractMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ar, err := mail.ParseAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Recipient{
|
||||||
|
Address: *ar,
|
||||||
|
addrPolicy: a,
|
||||||
|
LocalPart: local,
|
||||||
|
Domain: domain,
|
||||||
|
Mailbox: mailbox,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
|
||||||
|
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
if a.Config.SMTP.DefaultAccept &&
|
||||||
|
!stringutil.SliceContains(a.Config.SMTP.RejectDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !a.Config.SMTP.DefaultAccept &&
|
||||||
|
stringutil.SliceContains(a.Config.SMTP.AcceptDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldStoreDomain indicates if Inbucket stores mail destined for the specified domain.
|
||||||
|
func (a *Addressing) ShouldStoreDomain(domain string) bool {
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
if a.Config.SMTP.DefaultStore &&
|
||||||
|
!stringutil.SliceContains(a.Config.SMTP.DiscardDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !a.Config.SMTP.DefaultStore &&
|
||||||
|
stringutil.SliceContains(a.Config.SMTP.StoreDomains, domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
|
||||||
|
// An error is returned if the local or domain parts fail validation following the guidelines
|
||||||
|
// in RFC3696.
|
||||||
|
func ParseEmailAddress(address string) (local string, domain string, err error) {
|
||||||
|
local, domain, err = parseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !ValidateDomainPart(domain) {
|
||||||
|
return "", "", fmt.Errorf("Domain part validation failed")
|
||||||
|
}
|
||||||
|
return local, domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
|
||||||
|
// ParseEmailAddress().
|
||||||
|
func ValidateDomainPart(domain string) bool {
|
||||||
|
if len(domain) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(domain) > 255 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if domain[len(domain)-1] != '.' {
|
||||||
|
domain += "."
|
||||||
|
}
|
||||||
|
prev := '.'
|
||||||
|
labelLen := 0
|
||||||
|
hasAlphaNum := false
|
||||||
|
for _, c := range domain {
|
||||||
|
switch {
|
||||||
|
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
||||||
|
('0' <= c && c <= '9') || c == '_':
|
||||||
|
// Must contain some of these to be a valid label.
|
||||||
|
hasAlphaNum = true
|
||||||
|
labelLen++
|
||||||
|
case c == '-':
|
||||||
|
if prev == '.' || prev == '-' {
|
||||||
|
// Cannot lead with hyphen or double hyphen.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case c == '.':
|
||||||
|
if prev == '.' || prev == '-' {
|
||||||
|
// Cannot end with hyphen or double-dot.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if labelLen > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !hasAlphaNum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
labelLen = 0
|
||||||
|
hasAlphaNum = false
|
||||||
|
default:
|
||||||
|
// Unknown character.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prev = c
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
|
||||||
|
// error is returned if the local part fails validation following the guidelines in RFC3696. The
|
||||||
|
// domain part is optional and not validated.
|
||||||
|
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||||
|
if address == "" {
|
||||||
|
return "", "", fmt.Errorf("empty address")
|
||||||
|
}
|
||||||
|
if len(address) > 320 {
|
||||||
|
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||||
|
}
|
||||||
|
if address[0] == '@' {
|
||||||
|
return "", "", fmt.Errorf("address cannot start with @ symbol")
|
||||||
|
}
|
||||||
|
if address[0] == '.' {
|
||||||
|
return "", "", fmt.Errorf("address cannot start with a period")
|
||||||
|
}
|
||||||
|
// Loop over address parsing out local part.
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
prev := byte('.')
|
||||||
|
inCharQuote := false
|
||||||
|
inStringQuote := false
|
||||||
|
LOOP:
|
||||||
|
for i := 0; i < len(address); i++ {
|
||||||
|
c := address[i]
|
||||||
|
switch {
|
||||||
|
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
|
||||||
|
// Letters are OK.
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
case '0' <= c && c <= '9':
|
||||||
|
// Numbers are OK.
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
|
||||||
|
// These specials can be used unquoted.
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
case c == '.':
|
||||||
|
// A single period is OK.
|
||||||
|
if prev == '.' {
|
||||||
|
// Sequence of periods is not permitted.
|
||||||
|
return "", "", fmt.Errorf("Sequence of periods is not permitted")
|
||||||
|
}
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
case c == '\\':
|
||||||
|
inCharQuote = true
|
||||||
|
case c == '"':
|
||||||
|
if inCharQuote {
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
} else if inStringQuote {
|
||||||
|
inStringQuote = false
|
||||||
|
} else {
|
||||||
|
if i == 0 {
|
||||||
|
inStringQuote = true
|
||||||
|
} else {
|
||||||
|
return "", "", fmt.Errorf("Quoted string can only begin at start of address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case c == '@':
|
||||||
|
if inCharQuote || inStringQuote {
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
} else {
|
||||||
|
// End of local-part.
|
||||||
|
if i > 128 {
|
||||||
|
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
if prev == '.' {
|
||||||
|
return "", "", fmt.Errorf("Local part cannot end with a period")
|
||||||
|
}
|
||||||
|
domain = address[i+1:]
|
||||||
|
break LOOP
|
||||||
|
}
|
||||||
|
case c > 127:
|
||||||
|
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
|
||||||
|
default:
|
||||||
|
if inCharQuote || inStringQuote {
|
||||||
|
err = buf.WriteByte(c)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inCharQuote = false
|
||||||
|
} else {
|
||||||
|
return "", "", fmt.Errorf("Character %q must be quoted", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = c
|
||||||
|
}
|
||||||
|
if inCharQuote {
|
||||||
|
return "", "", fmt.Errorf("Cannot end address with unterminated quoted-pair")
|
||||||
|
}
|
||||||
|
if inStringQuote {
|
||||||
|
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
|
||||||
|
}
|
||||||
|
return buf.String(), domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
|
||||||
|
// and returns just the mailbox name (ex: "user"). Returns an error if
|
||||||
|
// localPart contains invalid characters; it won't accept any that must be
|
||||||
|
// quoted according to RFC3696.
|
||||||
|
func parseMailboxName(localPart string) (result string, err error) {
|
||||||
|
if localPart == "" {
|
||||||
|
return "", fmt.Errorf("Mailbox name cannot be empty")
|
||||||
|
}
|
||||||
|
result = strings.ToLower(localPart)
|
||||||
|
invalid := make([]byte, 0, 10)
|
||||||
|
for i := 0; i < len(result); i++ {
|
||||||
|
c := result[i]
|
||||||
|
switch {
|
||||||
|
case 'a' <= c && c <= 'z':
|
||||||
|
case '0' <= c && c <= '9':
|
||||||
|
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
|
||||||
|
default:
|
||||||
|
invalid = append(invalid, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(invalid) > 0 {
|
||||||
|
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
|
||||||
|
}
|
||||||
|
if idx := strings.Index(result, "+"); idx > -1 {
|
||||||
|
result = result[0:idx]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
432
pkg/policy/address_test.go
Normal file
432
pkg/policy/address_test.go
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
package policy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAcceptDomain(t *testing.T) {
|
||||||
|
// Test with default accept.
|
||||||
|
ap := &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultAccept: true,
|
||||||
|
RejectDomains: []string{"a.deny.com", "deny.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases := []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "bar.com", want: true},
|
||||||
|
{domain: "DENY.com", want: false},
|
||||||
|
{domain: "a.deny.com", want: false},
|
||||||
|
{domain: "b.deny.com", want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldAcceptDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Test with default reject.
|
||||||
|
ap = &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultAccept: false,
|
||||||
|
AcceptDomains: []string{"a.allow.com", "allow.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases = []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "bar.com", want: false},
|
||||||
|
{domain: "ALLOW.com", want: true},
|
||||||
|
{domain: "a.allow.com", want: true},
|
||||||
|
{domain: "b.allow.com", want: false},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldAcceptDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldStoreDomain(t *testing.T) {
|
||||||
|
// Test with storage enabled.
|
||||||
|
ap := &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultStore: false,
|
||||||
|
StoreDomains: []string{"store.com", "a.store.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases := []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "foo.com", want: false},
|
||||||
|
{domain: "STORE.com", want: true},
|
||||||
|
{domain: "a.store.com", want: true},
|
||||||
|
{domain: "b.store.com", want: false},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldStoreDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Test with storage disabled.
|
||||||
|
ap = &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultStore: true,
|
||||||
|
DiscardDomains: []string{"discard.com", "a.discard.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testCases = []struct {
|
||||||
|
domain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{domain: "foo.com", want: true},
|
||||||
|
{domain: "DISCARD.com", want: false},
|
||||||
|
{domain: "a.discard.com", want: false},
|
||||||
|
{domain: "b.discard.com", want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
got := ap.ShouldStoreDomain(tc.domain)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractMailboxValid(t *testing.T) {
|
||||||
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
|
|
||||||
|
testTable := []struct {
|
||||||
|
input string // Input to test
|
||||||
|
local string // Expected output when mailbox naming = local
|
||||||
|
full string // Expected output when mailbox naming = full
|
||||||
|
domain string // Expected output when mailbox naming = domain
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "mailbox",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox",
|
||||||
|
domain: "mailbox",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user123",
|
||||||
|
local: "user123",
|
||||||
|
full: "user123",
|
||||||
|
domain: "user123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "MailBOX",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox",
|
||||||
|
domain: "mailbox",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "First.Last",
|
||||||
|
local: "first.last",
|
||||||
|
full: "first.last",
|
||||||
|
domain: "first.last",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user+label",
|
||||||
|
local: "user",
|
||||||
|
full: "user",
|
||||||
|
domain: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars!#$%",
|
||||||
|
local: "chars!#$%",
|
||||||
|
full: "chars!#$%",
|
||||||
|
domain: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars&'*-",
|
||||||
|
local: "chars&'*-",
|
||||||
|
full: "chars&'*-",
|
||||||
|
domain: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars=/?^",
|
||||||
|
local: "chars=/?^",
|
||||||
|
full: "chars=/?^",
|
||||||
|
domain: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars_`.{",
|
||||||
|
local: "chars_`.{",
|
||||||
|
full: "chars_`.{",
|
||||||
|
domain: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~",
|
||||||
|
domain: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "mailbox@domain.com",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user123@domain.com",
|
||||||
|
local: "user123",
|
||||||
|
full: "user123@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "MailBOX@domain.com",
|
||||||
|
local: "mailbox",
|
||||||
|
full: "mailbox@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "First.Last@domain.com",
|
||||||
|
local: "first.last",
|
||||||
|
full: "first.last@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "user+label@domain.com",
|
||||||
|
local: "user",
|
||||||
|
full: "user@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars!#$%@domain.com",
|
||||||
|
local: "chars!#$%",
|
||||||
|
full: "chars!#$%@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars&'*-@domain.com",
|
||||||
|
local: "chars&'*-",
|
||||||
|
full: "chars&'*-@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars=/?^@domain.com",
|
||||||
|
local: "chars=/?^",
|
||||||
|
full: "chars=/?^@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars_`.{@domain.com",
|
||||||
|
local: "chars_`.{",
|
||||||
|
full: "chars_`.{@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~@domain.com",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~@example.co.uk",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~@example.co.uk",
|
||||||
|
domain: "example.co.uk",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testTable {
|
||||||
|
if result, err := localPolicy.ExtractMailbox(tc.input); err != nil {
|
||||||
|
t.Errorf("Error while parsing with local naming %q: %v", tc.input, err)
|
||||||
|
} else {
|
||||||
|
if result != tc.local {
|
||||||
|
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result, err := fullPolicy.ExtractMailbox(tc.input); err != nil {
|
||||||
|
t.Errorf("Error while parsing with full naming %q: %v", tc.input, err)
|
||||||
|
} else {
|
||||||
|
if result != tc.full {
|
||||||
|
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
|
||||||
|
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
|
||||||
|
} else {
|
||||||
|
if result != tc.domain {
|
||||||
|
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractMailboxInvalid(t *testing.T) {
|
||||||
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
|
// Test local mailbox naming policy.
|
||||||
|
localInvalidTable := []struct {
|
||||||
|
input, msg string
|
||||||
|
}{
|
||||||
|
{"", "Empty mailbox name is not permitted"},
|
||||||
|
{"first last", "Space not permitted"},
|
||||||
|
{"first\"last", "Double quote not permitted"},
|
||||||
|
{"first\nlast", "Control chars not permitted"},
|
||||||
|
}
|
||||||
|
for _, tt := range localInvalidTable {
|
||||||
|
if _, err := localPolicy.ExtractMailbox(tt.input); err == nil {
|
||||||
|
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test full mailbox naming policy.
|
||||||
|
fullInvalidTable := []struct {
|
||||||
|
input, msg string
|
||||||
|
}{
|
||||||
|
{"", "Empty mailbox name is not permitted"},
|
||||||
|
{"user@host@domain.com", "@ symbol not permitted"},
|
||||||
|
{"first last@domain.com", "Space not permitted"},
|
||||||
|
{"first\"last@domain.com", "Double quote not permitted"},
|
||||||
|
{"first\nlast@domain.com", "Control chars not permitted"},
|
||||||
|
}
|
||||||
|
for _, tt := range fullInvalidTable {
|
||||||
|
if _, err := fullPolicy.ExtractMailbox(tt.input); err == nil {
|
||||||
|
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test domain mailbox naming policy.
|
||||||
|
domainInvalidTable := []struct {
|
||||||
|
input, msg string
|
||||||
|
}{
|
||||||
|
{"", "Empty mailbox name is not permitted"},
|
||||||
|
{"user@host@domain.com", "@ symbol not permitted"},
|
||||||
|
{"first.last@dom ain.com", "Space not permitted"},
|
||||||
|
{"first\"last@domain.com", "Double quote not permitted"},
|
||||||
|
{"first\nlast@domain.com", "Control chars not permitted"},
|
||||||
|
{"first.last@chars!#$%.com", "Invalid domain name"},
|
||||||
|
{"first.last@.example.com", "Domain cannot start with dot"},
|
||||||
|
{"first.last@-example.com", "Domain canont start with dash"},
|
||||||
|
{"first.last@example.com-", "Domain cannot end with dash"},
|
||||||
|
{"first.last@example..com", "Domain cannot contain double dots"},
|
||||||
|
{"first.last@example--com", "Domain cannot contain double dashes"},
|
||||||
|
{"first.last@example.-com", "Domain cannot contain concecutive symbols"},
|
||||||
|
}
|
||||||
|
for _, tt := range domainInvalidTable {
|
||||||
|
if _, err := domainPolicy.ExtractMailbox(tt.input); err == nil {
|
||||||
|
t.Errorf("Didn't get an error while parsing in domain mode %q: %v", tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomain(t *testing.T) {
|
||||||
|
testTable := []struct {
|
||||||
|
input string
|
||||||
|
expect bool
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{"", false, "Empty domain is not valid"},
|
||||||
|
{"hostname", true, "Just a hostname is valid"},
|
||||||
|
{"github.com", true, "Two labels should be just fine"},
|
||||||
|
{"my-domain.com", true, "Hyphen is allowed mid-label"},
|
||||||
|
{"_domainkey.foo.com", true, "Underscores are allowed"},
|
||||||
|
{"bar.com.", true, "Must be able to end with a dot"},
|
||||||
|
{"ABC.6DBS.com", true, "Mixed case is OK"},
|
||||||
|
{"mail.123.com", true, "Number only label valid"},
|
||||||
|
{"123.com", true, "Number only label valid"},
|
||||||
|
{"google..com", false, "Double dot not valid"},
|
||||||
|
{".foo.com", false, "Cannot start with a dot"},
|
||||||
|
{"google\r.com", false, "Special chars not allowed"},
|
||||||
|
{"foo.-bar.com", false, "Label cannot start with hyphen"},
|
||||||
|
{"foo-.bar.com", false, "Label cannot end with hyphen"},
|
||||||
|
{strings.Repeat("a", 256), false, "Max domain length is 255"},
|
||||||
|
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
|
||||||
|
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
|
||||||
|
}
|
||||||
|
for _, tt := range testTable {
|
||||||
|
if policy.ValidateDomainPart(tt.input) != tt.expect {
|
||||||
|
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLocal(t *testing.T) {
|
||||||
|
testTable := []struct {
|
||||||
|
input string
|
||||||
|
expect bool
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{"", false, "Empty local is not valid"},
|
||||||
|
{"a", true, "Single letter should be fine"},
|
||||||
|
{strings.Repeat("a", 128), true, "Valid up to 128 characters"},
|
||||||
|
{strings.Repeat("a", 129), false, "Only valid up to 128 characters"},
|
||||||
|
{"FirstLast", true, "Mixed case permitted"},
|
||||||
|
{"user123", true, "Numbers permitted"},
|
||||||
|
{"a!#$%&'*+-/=?^_`{|}~", true, "Any of !#$%&'*+-/=?^_`{|}~ are permitted"},
|
||||||
|
{"first.last", true, "Embedded period is permitted"},
|
||||||
|
{"first..last", false, "Sequence of periods is not allowed"},
|
||||||
|
{".user", false, "Cannot lead with a period"},
|
||||||
|
{"user.", false, "Cannot end with a period"},
|
||||||
|
// {"james@mail", false, "Unquoted @ not permitted"},
|
||||||
|
{"first last", false, "Unquoted space not permitted"},
|
||||||
|
{"tricky\\. ", false, "Unquoted space not permitted"},
|
||||||
|
{"no,commas", false, "Unquoted comma not allowed"},
|
||||||
|
{"t[es]t", false, "Unquoted square brackets not allowed"},
|
||||||
|
// {"james\\", false, "Cannot end with backslash quote"},
|
||||||
|
{"james\\@mail", true, "Quoted @ permitted"},
|
||||||
|
{"quoted\\ space", true, "Quoted space permitted"},
|
||||||
|
{"no\\,commas", true, "Quoted comma is OK"},
|
||||||
|
{"t\\[es\\]t", true, "Quoted brackets are OK"},
|
||||||
|
{"user\\name", true, "Should be able to quote a-z"},
|
||||||
|
{"USER\\NAME", true, "Should be able to quote A-Z"},
|
||||||
|
{"user\\1", true, "Should be able to quote a digit"},
|
||||||
|
{"one\\$\\|", true, "Should be able to quote plain specials"},
|
||||||
|
{"return\\\r", true, "Should be able to quote ASCII control chars"},
|
||||||
|
{"high\\\x80", false, "Should not accept > 7-bit quoted chars"},
|
||||||
|
{"quote\\\"", true, "Quoted double quote is permitted"},
|
||||||
|
{"\"james\"", true, "Quoted a-z is permitted"},
|
||||||
|
{"\"first last\"", true, "Quoted space is permitted"},
|
||||||
|
{"\"quoted@sign\"", true, "Quoted @ is allowed"},
|
||||||
|
{"\"qp\\\"quote\"", true, "Quoted quote within quoted string is OK"},
|
||||||
|
{"\"unterminated", false, "Quoted string must be terminated"},
|
||||||
|
{"\"unterminated\\\"", false, "Quoted string must be terminated"},
|
||||||
|
{"embed\"quote\"string", false, "Embedded quoted string is illegal"},
|
||||||
|
{"user+mailbox", true, "RFC3696 test case should be valid"},
|
||||||
|
{"customer/department=shipping", true, "RFC3696 test case should be valid"},
|
||||||
|
{"$A12345", true, "RFC3696 test case should be valid"},
|
||||||
|
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
|
||||||
|
{"_somename", true, "RFC3696 test case should be valid"},
|
||||||
|
}
|
||||||
|
for _, tt := range testTable {
|
||||||
|
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
|
||||||
|
if (err != nil) == tt.expect {
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Got error: %s", err)
|
||||||
|
}
|
||||||
|
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
pkg/policy/recipient.go
Normal file
25
pkg/policy/recipient.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import "net/mail"
|
||||||
|
|
||||||
|
// Recipient represents a potential email recipient, allows policies for it to be queried.
|
||||||
|
type Recipient struct {
|
||||||
|
mail.Address
|
||||||
|
addrPolicy *Addressing
|
||||||
|
// LocalPart is the part of the address before @, including +extension.
|
||||||
|
LocalPart string
|
||||||
|
// Domain is the part of the address after @.
|
||||||
|
Domain string
|
||||||
|
// Mailbox is the canonical mailbox name for this recipient.
|
||||||
|
Mailbox string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldAccept returns true if Inbucket should accept mail for this recipient.
|
||||||
|
func (r *Recipient) ShouldAccept() bool {
|
||||||
|
return r.addrPolicy.ShouldAcceptDomain(r.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldStore returns true if Inbucket should store mail for this recipient.
|
||||||
|
func (r *Recipient) ShouldStore() bool {
|
||||||
|
return r.addrPolicy.ShouldStoreDomain(r.Domain)
|
||||||
|
}
|
||||||
182
pkg/rest/apiv1_controller.go
Normal file
182
pkg/rest/apiv1_controller.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailboxListV1 renders a list of messages in a mailbox
|
||||||
|
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
messages, err := ctx.Manager.GetMetadata(name)
|
||||||
|
if err != nil {
|
||||||
|
// This doesn't indicate empty, likely an IO error
|
||||||
|
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
||||||
|
}
|
||||||
|
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||||
|
Mailbox: name,
|
||||||
|
ID: msg.ID,
|
||||||
|
From: stringutil.StringAddress(msg.From),
|
||||||
|
To: stringutil.StringAddressList(msg.To),
|
||||||
|
Subject: msg.Subject,
|
||||||
|
Date: msg.Date,
|
||||||
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, jmessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxShowV1 renders a particular message from a mailbox
|
||||||
|
func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := ctx.Manager.GetMessage(name, id)
|
||||||
|
if err != nil && err != storage.ErrNotExist {
|
||||||
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
attachParts := msg.Attachments()
|
||||||
|
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
||||||
|
for i, part := range attachParts {
|
||||||
|
content := part.Content
|
||||||
|
// Example URL: http://localhost/serve/mailbox/swaks/0001/attach/0/favicon.png
|
||||||
|
link := "http://" + req.Host + "/serve/mailbox/" + name + "/" + id + "/attach/" +
|
||||||
|
strconv.Itoa(i) + "/" + part.FileName
|
||||||
|
checksum := md5.Sum(content)
|
||||||
|
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||||
|
ContentType: part.ContentType,
|
||||||
|
FileName: part.FileName,
|
||||||
|
DownloadLink: link,
|
||||||
|
ViewLink: link,
|
||||||
|
MD5: hex.EncodeToString(checksum[:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w,
|
||||||
|
&model.JSONMessageV1{
|
||||||
|
Mailbox: name,
|
||||||
|
ID: msg.ID,
|
||||||
|
From: stringutil.StringAddress(msg.From),
|
||||||
|
To: stringutil.StringAddressList(msg.To),
|
||||||
|
Subject: msg.Subject,
|
||||||
|
Date: msg.Date,
|
||||||
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
|
Header: msg.Header(),
|
||||||
|
Body: &model.JSONMessageBodyV1{
|
||||||
|
Text: msg.Text(),
|
||||||
|
HTML: msg.HTML(),
|
||||||
|
},
|
||||||
|
Attachments: attachments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxMarkSeenV1 marks a message as read.
|
||||||
|
func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(req.Body)
|
||||||
|
dm := model.JSONMessageHeaderV1{}
|
||||||
|
if err := dec.Decode(&dm); err != nil {
|
||||||
|
return fmt.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if dm.Seen {
|
||||||
|
err = ctx.Manager.MarkSeen(name, id)
|
||||||
|
if err == storage.ErrNotExist {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// This doesn't indicate empty, likely an IO error
|
||||||
|
return fmt.Errorf("MarkSeen(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxPurgeV1 deletes all messages from a mailbox
|
||||||
|
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete all messages
|
||||||
|
err = ctx.Manager.PurgeMessages(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err)
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxSourceV1 displays the raw source of a message, including headers. Renders text/plain
|
||||||
|
func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r, err := ctx.Manager.SourceReader(name, id)
|
||||||
|
if err != nil && err != storage.ErrNotExist {
|
||||||
|
return fmt.Errorf("SourceReader(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Output message source
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxDeleteV1 removes a particular message from a mailbox
|
||||||
|
func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
id := ctx.Vars["id"]
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = ctx.Manager.RemoveMessage(name, id)
|
||||||
|
if err == storage.ErrNotExist {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// This doesn't indicate missing, likely an IO error
|
||||||
|
return fmt.Errorf("RemoveMessage(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
return web.RenderJSON(w, "OK")
|
||||||
|
}
|
||||||
313
pkg/rest/apiv1_controller_test.go
Normal file
313
pkg/rest/apiv1_controller_test.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JSON map keys
|
||||||
|
mailboxKey = "mailbox"
|
||||||
|
idKey = "id"
|
||||||
|
fromKey = "from"
|
||||||
|
toKey = "to"
|
||||||
|
subjectKey = "subject"
|
||||||
|
dateKey = "date"
|
||||||
|
sizeKey = "size"
|
||||||
|
headerKey = "header"
|
||||||
|
bodyKey = "body"
|
||||||
|
textKey = "text"
|
||||||
|
htmlKey = "html"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRestMailboxList(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
mm := test.NewManager()
|
||||||
|
logbuf := setupWebServer(mm)
|
||||||
|
|
||||||
|
// Test invalid mailbox name
|
||||||
|
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
|
||||||
|
expectCode := 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test empty mailbox
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/empty")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Mailbox error
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/messageserr")
|
||||||
|
expectCode = 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test JSON message headers
|
||||||
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
|
meta1 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0001",
|
||||||
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 1",
|
||||||
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
|
}
|
||||||
|
meta2 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0002",
|
||||||
|
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 2",
|
||||||
|
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||||
|
}
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||||
|
|
||||||
|
// Check return code
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON
|
||||||
|
dec := json.NewDecoder(w.Body)
|
||||||
|
var result []interface{}
|
||||||
|
if err := dec.Decode(&result); err != nil {
|
||||||
|
t.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("Expected 2 results, got %v", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedStringEquals(t, result, "[0]/mailbox", "good")
|
||||||
|
decodedStringEquals(t, result, "[0]/id", "0001")
|
||||||
|
decodedStringEquals(t, result, "[0]/from", "<from1@host>")
|
||||||
|
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||||
|
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||||
|
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
|
||||||
|
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||||
|
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||||
|
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
||||||
|
decodedStringEquals(t, result, "[1]/id", "0002")
|
||||||
|
decodedStringEquals(t, result, "[1]/from", "<from2@host>")
|
||||||
|
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
||||||
|
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||||
|
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
||||||
|
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
|
||||||
|
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||||
|
decodedBoolEquals(t, result, "[1]/seen", false)
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestMessage(t *testing.T) {
|
||||||
|
// Setup
|
||||||
|
mm := test.NewManager()
|
||||||
|
logbuf := setupWebServer(mm)
|
||||||
|
|
||||||
|
// Test invalid mailbox name
|
||||||
|
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
|
||||||
|
expectCode := 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test requesting a message that does not exist
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/empty/0001")
|
||||||
|
expectCode = 404
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetMessage error
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/messageerr/0001")
|
||||||
|
expectCode = 500
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test JSON message headers
|
||||||
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
|
msg1 := message.New(
|
||||||
|
message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0001",
|
||||||
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 1",
|
||||||
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
|
Seen: true,
|
||||||
|
},
|
||||||
|
&enmime.Envelope{
|
||||||
|
Text: "This is some text",
|
||||||
|
HTML: "This is some HTML",
|
||||||
|
Root: &enmime.Part{
|
||||||
|
Header: textproto.MIMEHeader{
|
||||||
|
"To": []string{"fred@fish.com", "keyword@nsa.gov"},
|
||||||
|
"From": []string{"noreply@inbucket.org"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Attachments: []*enmime.Part{{
|
||||||
|
FileName: "favicon.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mm.AddMessage("good", msg1)
|
||||||
|
|
||||||
|
// Check return code
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good/0001")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON
|
||||||
|
dec := json.NewDecoder(w.Body)
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := dec.Decode(&result); err != nil {
|
||||||
|
t.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedStringEquals(t, result, "mailbox", "good")
|
||||||
|
decodedStringEquals(t, result, "id", "0001")
|
||||||
|
decodedStringEquals(t, result, "from", "<from1@host>")
|
||||||
|
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
||||||
|
decodedStringEquals(t, result, "subject", "subject 1")
|
||||||
|
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
|
||||||
|
decodedNumberEquals(t, result, "size", 0)
|
||||||
|
decodedBoolEquals(t, result, "seen", true)
|
||||||
|
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||||
|
decodedStringEquals(t, result, "body/html", "This is some HTML")
|
||||||
|
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
||||||
|
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
||||||
|
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestMarkSeen(t *testing.T) {
|
||||||
|
mm := test.NewManager()
|
||||||
|
logbuf := setupWebServer(mm)
|
||||||
|
// Create some messages.
|
||||||
|
tzPDT := time.FixedZone("PDT", -7*3600)
|
||||||
|
tzPST := time.FixedZone("PST", -8*3600)
|
||||||
|
meta1 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0001",
|
||||||
|
From: &mail.Address{Name: "", Address: "from1@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 1",
|
||||||
|
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
|
||||||
|
}
|
||||||
|
meta2 := message.Metadata{
|
||||||
|
Mailbox: "good",
|
||||||
|
ID: "0002",
|
||||||
|
From: &mail.Address{Name: "", Address: "from2@host"},
|
||||||
|
To: []*mail.Address{{Name: "", Address: "to1@host"}},
|
||||||
|
Subject: "subject 2",
|
||||||
|
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
|
||||||
|
}
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||||
|
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||||
|
// Mark one read.
|
||||||
|
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
|
||||||
|
expectCode := 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
// Get mailbox.
|
||||||
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||||
|
expectCode = 200
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != expectCode {
|
||||||
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
|
}
|
||||||
|
// Check JSON.
|
||||||
|
dec := json.NewDecoder(w.Body)
|
||||||
|
var result []interface{}
|
||||||
|
if err := dec.Decode(&result); err != nil {
|
||||||
|
t.Errorf("Failed to decode JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("Expected 2 results, got %v", len(result))
|
||||||
|
}
|
||||||
|
decodedStringEquals(t, result, "[0]/id", "0001")
|
||||||
|
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||||
|
decodedStringEquals(t, result, "[1]/id", "0002")
|
||||||
|
decodedBoolEquals(t, result, "[1]/seen", true)
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
154
pkg/rest/client/apiv1_client.go
Normal file
154
pkg/rest/client/apiv1_client.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Package client provides a basic REST client for Inbucket
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client accesses the Inbucket REST API v1
|
||||||
|
type Client struct {
|
||||||
|
restClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
|
||||||
|
// "http://localhost:9000"
|
||||||
|
func New(baseURL string) (*Client, error) {
|
||||||
|
parsedURL, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &Client{
|
||||||
|
restClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
baseURL: parsedURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMailbox returns a list of messages for the requested mailbox
|
||||||
|
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||||
|
err = c.doJSON("GET", uri, &headers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, h := range headers {
|
||||||
|
h.client = c
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns the message details given a mailbox name and message ID.
|
||||||
|
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||||
|
err = c.doJSON("GET", uri, &message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
message.client = c
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen marks the specified message as having been read.
|
||||||
|
func (c *Client) MarkSeen(name, id string) error {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||||
|
err := c.doJSON("PATCH", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessageSource returns the message source given a mailbox name and message ID.
|
||||||
|
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
|
||||||
|
resp, err := c.do("GET", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil,
|
||||||
|
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, err = buf.ReadFrom(resp.Body)
|
||||||
|
return buf, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage deletes a single message given the mailbox name and message ID.
|
||||||
|
func (c *Client) DeleteMessage(name, id string) error {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||||
|
resp, err := c.do("DELETE", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMailbox deletes all messages in the given mailbox
|
||||||
|
func (c *Client) PurgeMailbox(name string) error {
|
||||||
|
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||||
|
resp, err := c.do("DELETE", uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageHeader represents an Inbucket message sans content
|
||||||
|
type MessageHeader struct {
|
||||||
|
*model.JSONMessageHeaderV1
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns this message with content
|
||||||
|
func (h *MessageHeader) GetMessage() (message *Message, err error) {
|
||||||
|
return h.client.GetMessage(h.Mailbox, h.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSource returns the source for this message
|
||||||
|
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
|
||||||
|
return h.client.GetMessageSource(h.Mailbox, h.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes this message from the mailbox
|
||||||
|
func (h *MessageHeader) Delete() error {
|
||||||
|
return h.client.DeleteMessage(h.Mailbox, h.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents an Inbucket message including content
|
||||||
|
type Message struct {
|
||||||
|
*model.JSONMessageV1
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSource returns the source for this message
|
||||||
|
func (m *Message) GetSource() (*bytes.Buffer, error) {
|
||||||
|
return m.client.GetMessageSource(m.Mailbox, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes this message from the mailbox
|
||||||
|
func (m *Message) Delete() error {
|
||||||
|
return m.client.DeleteMessage(m.Mailbox, m.ID)
|
||||||
|
}
|
||||||
361
pkg/rest/client/apiv1_client_test.go
Normal file
361
pkg/rest/client/apiv1_client_test.go
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientV1ListMailbox(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
listHandler := &jsonHandler{json: `[
|
||||||
|
{
|
||||||
|
"mailbox": "testbox",
|
||||||
|
"id": "1",
|
||||||
|
"from": "fromuser",
|
||||||
|
"subject": "test subject",
|
||||||
|
"date": "2013-10-15T16:12:02.231532239-07:00",
|
||||||
|
"size": 264,
|
||||||
|
"seen": true
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
|
||||||
|
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
headers, err := c.ListMailbox("testbox")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(headers) != 1 {
|
||||||
|
t.Fatalf("Got %v headers, want 1", len(headers))
|
||||||
|
}
|
||||||
|
h := headers[0]
|
||||||
|
|
||||||
|
got := h.Mailbox
|
||||||
|
want := "testbox"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Mailbox got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = h.ID
|
||||||
|
want = "1"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ID got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = h.From
|
||||||
|
want = "fromuser"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("From got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = h.Subject
|
||||||
|
want = "test subject"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Subject got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotTime := h.Date
|
||||||
|
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
|
||||||
|
if !wantTime.Equal(gotTime) {
|
||||||
|
t.Errorf("Date got %v, want %v", gotTime, wantTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotInt := h.Size
|
||||||
|
wantInt := int64(264)
|
||||||
|
if gotInt != wantInt {
|
||||||
|
t.Errorf("Size got %v, want %v", gotInt, wantInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantBool := true
|
||||||
|
gotBool := h.Seen
|
||||||
|
if gotBool != wantBool {
|
||||||
|
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientV1GetMessage(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
messageHandler := &jsonHandler{json: `{
|
||||||
|
"mailbox": "testbox",
|
||||||
|
"id": "20170107T224128-0000",
|
||||||
|
"from": "fromuser",
|
||||||
|
"subject": "test subject",
|
||||||
|
"date": "2013-10-15T16:12:02.231532239-07:00",
|
||||||
|
"size": 264,
|
||||||
|
"seen": true,
|
||||||
|
"body": {
|
||||||
|
"text": "Plain text",
|
||||||
|
"html": "<html>"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
|
||||||
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("GET").Handler(messageHandler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
m, err := c.GetMessage("testbox", "20170107T224128-0000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("message was nil, wanted a value")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := m.Mailbox
|
||||||
|
want := "testbox"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Mailbox got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.ID
|
||||||
|
want = "20170107T224128-0000"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ID got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.From
|
||||||
|
want = "fromuser"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("From got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.Subject
|
||||||
|
want = "test subject"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Subject got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotTime := m.Date
|
||||||
|
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
|
||||||
|
if !wantTime.Equal(gotTime) {
|
||||||
|
t.Errorf("Date got %v, want %v", gotTime, wantTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotInt := m.Size
|
||||||
|
wantInt := int64(264)
|
||||||
|
if gotInt != wantInt {
|
||||||
|
t.Errorf("Size got %v, want %v", gotInt, wantInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotBool := m.Seen
|
||||||
|
wantBool := true
|
||||||
|
if gotBool != wantBool {
|
||||||
|
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.Body.Text
|
||||||
|
want = "Plain text"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Body Text got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.Body.HTML
|
||||||
|
want = "<html>"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Body HTML got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientV1MarkSeen(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
handler := &jsonHandler{}
|
||||||
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("PATCH").
|
||||||
|
Handler(handler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
err := c.MarkSeen("testbox", "20170107T224128-0000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !handler.called {
|
||||||
|
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientV1GetMessageSource(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
|
||||||
|
Handler(&jsonHandler{json: `message source`})
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "message source"
|
||||||
|
got := source.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Source got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientV1DeleteMessage(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
handler := &jsonHandler{}
|
||||||
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("DELETE").
|
||||||
|
Handler(handler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
err := c.DeleteMessage("testbox", "20170107T224128-0000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !handler.called {
|
||||||
|
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientV1PurgeMailbox(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
handler := &jsonHandler{}
|
||||||
|
router.Path("/api/v1/mailbox/testbox").Methods("DELETE").Handler(handler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
err := c.PurgeMailbox("testbox")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !handler.called {
|
||||||
|
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientV1MessageHeader(t *testing.T) {
|
||||||
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
listHandler := &jsonHandler{json: `[
|
||||||
|
{
|
||||||
|
"mailbox":"mailbox1",
|
||||||
|
"id":"id1",
|
||||||
|
"from":"from1",
|
||||||
|
"subject":"subject1",
|
||||||
|
"date":"2017-01-01T00:00:00.000-07:00",
|
||||||
|
"size":100,
|
||||||
|
"seen":true
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
headers, err := c.ListMailbox("testbox")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(headers) != 1 {
|
||||||
|
t.Fatalf("len(headers) == %v, want 1", len(headers))
|
||||||
|
}
|
||||||
|
header := headers[0]
|
||||||
|
|
||||||
|
// Test MessageHeader.Delete().
|
||||||
|
handler := &jsonHandler{}
|
||||||
|
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("DELETE").Handler(handler)
|
||||||
|
err = header.Delete()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test MessageHeader.GetSource().
|
||||||
|
router.Path("/api/v1/mailbox/mailbox1/id1/source").Methods("GET").
|
||||||
|
Handler(&jsonHandler{json: `source1`})
|
||||||
|
buf, err := header.GetSource()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "source1"
|
||||||
|
got := buf.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Got source %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test MessageHeader.GetMessage().
|
||||||
|
messageHandler := &jsonHandler{json: `{
|
||||||
|
"mailbox":"mailbox1",
|
||||||
|
"id":"id1",
|
||||||
|
"from":"from1",
|
||||||
|
"subject":"subject1",
|
||||||
|
"date":"2017-01-01T00:00:00.000-07:00",
|
||||||
|
"size":100
|
||||||
|
}`}
|
||||||
|
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("GET").Handler(messageHandler)
|
||||||
|
message, err := header.GetMessage()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if message == nil {
|
||||||
|
t.Fatalf("message was nil, wanted a value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Message.Delete().
|
||||||
|
err = message.Delete()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Message.GetSource().
|
||||||
|
buf, err = message.GetSource()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "source1"
|
||||||
|
got = buf.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Got source %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup returns a client, router and server for API testing.
|
||||||
|
func setup() (c *client.Client, router *mux.Router, teardown func()) {
|
||||||
|
router = mux.NewRouter()
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
c, err := client.New(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return c, router, func() {
|
||||||
|
server.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonHandler returns the string in json when servicing a request.
|
||||||
|
type jsonHandler struct {
|
||||||
|
json string
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j.called = true
|
||||||
|
w.Write([]byte(j.json))
|
||||||
|
}
|
||||||
102
pkg/rest/client/example_test.go
Normal file
102
pkg/rest/client/example_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Example demonstrates basic usage for the Inbucket REST client.
|
||||||
|
func Example() {
|
||||||
|
// Setup a fake Inbucket server for this example.
|
||||||
|
baseURL, teardown := exampleSetup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
|
||||||
|
// `localhost:9000`.
|
||||||
|
restClient, err := client.New(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a slice of message headers for the mailbox named `user1`.
|
||||||
|
headers, err := restClient.ListMailbox("user1")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, header := range headers {
|
||||||
|
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the content of the first message.
|
||||||
|
message, err := headers[0].GetMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nFrom: %v\n", message.From)
|
||||||
|
fmt.Printf("Text body:\n%v", message.Body.Text)
|
||||||
|
|
||||||
|
// Delete the second message.
|
||||||
|
err = headers[1].Delete()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// ID: 20180107T224128-0000, Subject: First subject
|
||||||
|
// ID: 20180108T121212-0123, Subject: Second subject
|
||||||
|
//
|
||||||
|
// From: admin@inbucket.org
|
||||||
|
// Text body:
|
||||||
|
// This is the plain text body
|
||||||
|
}
|
||||||
|
|
||||||
|
// exampleSetup creates a fake Inbucket server to power Example() below.
|
||||||
|
func exampleSetup() (baseURL string, teardown func()) {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
|
||||||
|
// Handle ListMailbox request.
|
||||||
|
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`[
|
||||||
|
{
|
||||||
|
"mailbox": "user1",
|
||||||
|
"id": "20180107T224128-0000",
|
||||||
|
"subject": "First subject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mailbox": "user1",
|
||||||
|
"id": "20180108T121212-0123",
|
||||||
|
"subject": "Second subject"
|
||||||
|
}
|
||||||
|
]`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle GetMessage request.
|
||||||
|
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{
|
||||||
|
"mailbox": "user1",
|
||||||
|
"id": "20180107T224128-0000",
|
||||||
|
"from": "admin@inbucket.org",
|
||||||
|
"subject": "First subject",
|
||||||
|
"body": {
|
||||||
|
"text": "This is the plain text body"
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle Delete request.
|
||||||
|
router.HandleFunc("/api/v1/mailbox/user1/20180108T121212-0123",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Nop.
|
||||||
|
})
|
||||||
|
|
||||||
|
return server.URL, func() {
|
||||||
|
server.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
81
pkg/rest/client/rest.go
Normal file
81
pkg/rest/client/rest.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpClient allows http.Client to be mocked for tests
|
||||||
|
type httpClient interface {
|
||||||
|
Do(*http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic REST restClient
|
||||||
|
type restClient struct {
|
||||||
|
client httpClient
|
||||||
|
baseURL *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs an HTTP request with this client and returns the response.
|
||||||
|
func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
|
||||||
|
rel, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
url := c.baseURL.ResolveReference(rel)
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
r = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, url.String(), r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
|
||||||
|
}
|
||||||
|
return c.client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
|
||||||
|
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
||||||
|
resp, err := c.do(method, uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Decode response body
|
||||||
|
return json.NewDecoder(resp.Body).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
|
||||||
|
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
|
||||||
|
resp, err := c.do(method, uri, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Decode response body
|
||||||
|
return json.NewDecoder(resp.Body).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
142
pkg/rest/client/rest_test.go
Normal file
142
pkg/rest/client/rest_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseURLStr = "http://test.local:8080"
|
||||||
|
|
||||||
|
var baseURL *url.URL
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
baseURL, err = url.Parse(baseURLStr)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockHTTPClient struct {
|
||||||
|
req *http.Request
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) {
|
||||||
|
m.req = req
|
||||||
|
if m.statusCode == 0 {
|
||||||
|
m.statusCode = 200
|
||||||
|
}
|
||||||
|
resp = &http.Response{
|
||||||
|
StatusCode: m.statusCode,
|
||||||
|
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHTTPClient) ReqBody() []byte {
|
||||||
|
r, err := m.req.GetBody()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := ioutil.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDo(t *testing.T) {
|
||||||
|
var want, got string
|
||||||
|
mth := &mockHTTPClient{}
|
||||||
|
c := &restClient{mth, baseURL}
|
||||||
|
body := []byte("Test body")
|
||||||
|
|
||||||
|
_, err := c.do("POST", "/dopost", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "POST"
|
||||||
|
got = mth.req.Method
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.Method == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = baseURLStr + "/dopost"
|
||||||
|
got = mth.req.URL.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.URL == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := mth.ReqBody()
|
||||||
|
if !bytes.Equal(b, body) {
|
||||||
|
t.Errorf("req.Body == %q, want %q", b, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSON(t *testing.T) {
|
||||||
|
var want, got string
|
||||||
|
|
||||||
|
mth := &mockHTTPClient{
|
||||||
|
body: `{"foo": "bar"}`,
|
||||||
|
}
|
||||||
|
c := &restClient{mth, baseURL}
|
||||||
|
|
||||||
|
var v map[string]interface{}
|
||||||
|
err := c.doJSON("GET", "/doget", &v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "GET"
|
||||||
|
got = mth.req.Method
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.Method == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = baseURLStr + "/doget"
|
||||||
|
got = mth.req.URL.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.URL == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "bar"
|
||||||
|
if val, ok := v["foo"]; ok {
|
||||||
|
got = val.(string)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("map[foo] == %q, want: %q", got, want)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Errorf("Map did not contain key foo, want: %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSONNilV(t *testing.T) {
|
||||||
|
var want, got string
|
||||||
|
|
||||||
|
mth := &mockHTTPClient{}
|
||||||
|
c := &restClient{mth, baseURL}
|
||||||
|
|
||||||
|
err := c.doJSON("GET", "/doget", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "GET"
|
||||||
|
got = mth.req.Method
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.Method == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = baseURLStr + "/doget"
|
||||||
|
got = mth.req.URL.String()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("req.URL == %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
pkg/rest/model/apiv1_model.go
Normal file
49
pkg/rest/model/apiv1_model.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||||
|
type JSONMessageHeaderV1 struct {
|
||||||
|
Mailbox string `json:"mailbox"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To []string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
||||||
|
type JSONMessageV1 struct {
|
||||||
|
Mailbox string `json:"mailbox"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To []string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
Body *JSONMessageBodyV1 `json:"body"`
|
||||||
|
Header map[string][]string `json:"header"`
|
||||||
|
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONMessageAttachmentV1 contains information about a MIME attachment
|
||||||
|
type JSONMessageAttachmentV1 struct {
|
||||||
|
FileName string `json:"filename"`
|
||||||
|
ContentType string `json:"content-type"`
|
||||||
|
DownloadLink string `json:"download-link"`
|
||||||
|
ViewLink string `json:"view-link"`
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
|
||||||
|
type JSONMessageBodyV1 struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
25
pkg/rest/routes.go
Normal file
25
pkg/rest/routes.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import "github.com/gorilla/mux"
|
||||||
|
import "github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
|
||||||
|
// SetupRoutes populates the routes for the REST interface
|
||||||
|
func SetupRoutes(r *mux.Router) {
|
||||||
|
// API v1
|
||||||
|
r.Path("/v1/mailbox/{name}").Handler(
|
||||||
|
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
|
||||||
|
r.Path("/v1/mailbox/{name}").Handler(
|
||||||
|
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
|
||||||
|
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||||
|
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
|
||||||
|
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||||
|
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
|
||||||
|
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||||
|
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
|
||||||
|
r.Path("/v1/mailbox/{name}/{id}/source").Handler(
|
||||||
|
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
|
||||||
|
r.Path("/v1/monitor/messages").Handler(
|
||||||
|
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
||||||
|
r.Path("/v1/monitor/messages/{name}").Handler(
|
||||||
|
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
||||||
|
}
|
||||||
198
pkg/rest/socketv1_controller.go
Normal file
198
pkg/rest/socketv1_controller.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Time allowed to write a message to the peer.
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
|
||||||
|
// Send pings to peer with this period. Must be less than pongWait.
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer.
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
|
||||||
|
// Maximum message size allowed from peer.
|
||||||
|
maxMessageSize = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
// options for gorilla connection upgrader
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
// msgListener handles messages from the msghub
|
||||||
|
type msgListener struct {
|
||||||
|
hub *msghub.Hub // Global message hub
|
||||||
|
c chan msghub.Message // Queue of messages from Receive()
|
||||||
|
mailbox string // Name of mailbox to monitor, "" == all mailboxes
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
|
||||||
|
// messages sent to WebSocket to that mailbox only.
|
||||||
|
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
|
||||||
|
ml := &msgListener{
|
||||||
|
hub: hub,
|
||||||
|
c: make(chan msghub.Message, 100),
|
||||||
|
mailbox: mailbox,
|
||||||
|
}
|
||||||
|
hub.AddListener(ml)
|
||||||
|
return ml
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive handles an incoming message
|
||||||
|
func (ml *msgListener) Receive(msg msghub.Message) error {
|
||||||
|
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
||||||
|
// Did not match mailbox name
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ml.c <- msg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
||||||
|
func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
||||||
|
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Logger()
|
||||||
|
defer ml.Close()
|
||||||
|
conn.SetReadLimit(maxMessageSize)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
slog.Debug().Msg("Got pong")
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.ReadMessage(); err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(
|
||||||
|
err,
|
||||||
|
websocket.CloseNormalClosure,
|
||||||
|
websocket.CloseGoingAway,
|
||||||
|
websocket.CloseNoStatusReceived,
|
||||||
|
) {
|
||||||
|
// Unexpected close code
|
||||||
|
slog.Warn().Err(err).Msg("Socket error")
|
||||||
|
} else {
|
||||||
|
slog.Debug().Msg("Closing socket")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSWriter makes sure the websocket client is still connected
|
||||||
|
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
ml.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Handle messages from hub until msgListener is closed
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-ml.c:
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
// msgListener closed, exit
|
||||||
|
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
header := &model.JSONMessageHeaderV1{
|
||||||
|
Mailbox: msg.Mailbox,
|
||||||
|
ID: msg.ID,
|
||||||
|
From: msg.From,
|
||||||
|
To: msg.To,
|
||||||
|
Subject: msg.Subject,
|
||||||
|
Date: msg.Date,
|
||||||
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
|
Size: msg.Size,
|
||||||
|
}
|
||||||
|
if conn.WriteJSON(header) != nil {
|
||||||
|
// Write failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
// Send ping
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
||||||
|
// Write error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close removes the listener registration
|
||||||
|
func (ml *msgListener) Close() {
|
||||||
|
select {
|
||||||
|
case <-ml.c:
|
||||||
|
// Already closed
|
||||||
|
default:
|
||||||
|
ml.hub.RemoveListener(ml)
|
||||||
|
close(ml.c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorAllMessagesV1 is a web handler which upgrades the connection to a websocket and notifies
|
||||||
|
// the client of all messages received.
|
||||||
|
func MonitorAllMessagesV1(
|
||||||
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
// Upgrade to Websocket.
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(1)
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(-1)
|
||||||
|
}()
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
|
// Create, register listener; then interact with conn.
|
||||||
|
ml := newMsgListener(ctx.MsgHub, "")
|
||||||
|
go ml.WSWriter(conn)
|
||||||
|
ml.WSReader(conn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorMailboxMessagesV1 is a web handler which upgrades the connection to a websocket and
|
||||||
|
// notifies the client of messages received by a particular mailbox.
|
||||||
|
func MonitorMailboxMessagesV1(
|
||||||
|
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Upgrade to Websocket.
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(1)
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
web.ExpWebSocketConnectsCurrent.Add(-1)
|
||||||
|
}()
|
||||||
|
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
|
||||||
|
// Create, register listener; then interact with conn.
|
||||||
|
ml := newMsgListener(ctx.MsgHub, name)
|
||||||
|
go ml.WSWriter(conn)
|
||||||
|
ml.WSReader(conn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
160
pkg/rest/testutils_test.go
Normal file
160
pkg/rest/testutils_test.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
web.Router.ServeHTTP(w, req)
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
|
||||||
|
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
web.Router.ServeHTTP(w, req)
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||||
|
// Capture log output
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
log.SetOutput(buf)
|
||||||
|
|
||||||
|
// Have to reset default mux to prevent duplicate routes
|
||||||
|
cfg := &config.Root{
|
||||||
|
Web: config.Web{
|
||||||
|
UIDir: "../ui",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
shutdownChan := make(chan bool)
|
||||||
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
|
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodedBoolEquals(t *testing.T, json interface{}, path string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
els := strings.Split(path, "/")
|
||||||
|
val, msg := getDecodedPath(json, els...)
|
||||||
|
if msg != "" {
|
||||||
|
t.Errorf("JSON result%s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got, ok := val.(bool); ok {
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodedNumberEquals(t *testing.T, json interface{}, path string, want float64) {
|
||||||
|
t.Helper()
|
||||||
|
els := strings.Split(path, "/")
|
||||||
|
val, msg := getDecodedPath(json, els...)
|
||||||
|
if msg != "" {
|
||||||
|
t.Errorf("JSON result%s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got, ok := val.(float64)
|
||||||
|
if ok {
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
|
||||||
|
path, val, val, int64(got), want, int64(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
els := strings.Split(path, "/")
|
||||||
|
val, msg := getDecodedPath(json, els...)
|
||||||
|
if msg != "" {
|
||||||
|
t.Errorf("JSON result%s", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got, ok := val.(string); ok {
|
||||||
|
if got == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDecodedPath recursively navigates the specified path, returing the requested element. If
|
||||||
|
// something goes wrong, the returned string will contain an explanation.
|
||||||
|
//
|
||||||
|
// Named path elements require the parent element to be a map[string]interface{}, numbers in square
|
||||||
|
// brackets require the parent element to be a []interface{}.
|
||||||
|
//
|
||||||
|
// getDecodedPath(o, "users", "[1]", "name")
|
||||||
|
//
|
||||||
|
// is equivalent to the JavaScript:
|
||||||
|
//
|
||||||
|
// o.users[1].name
|
||||||
|
//
|
||||||
|
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return o, ""
|
||||||
|
}
|
||||||
|
if o == nil {
|
||||||
|
return nil, " is nil"
|
||||||
|
}
|
||||||
|
key := path[0]
|
||||||
|
present := false
|
||||||
|
var val interface{}
|
||||||
|
if key[0] == '[' {
|
||||||
|
// Expecting slice.
|
||||||
|
index, err := strconv.Atoi(strings.Trim(key, "[]"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "/" + key + " is not a slice index"
|
||||||
|
}
|
||||||
|
oslice, ok := o.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, " is not a slice"
|
||||||
|
}
|
||||||
|
if index >= len(oslice) {
|
||||||
|
return nil, "/" + key + " is out of bounds"
|
||||||
|
}
|
||||||
|
val, present = oslice[index], true
|
||||||
|
} else {
|
||||||
|
// Expecting map.
|
||||||
|
omap, ok := o.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, " is not a map"
|
||||||
|
}
|
||||||
|
val, present = omap[key]
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
return nil, "/" + key + " is missing"
|
||||||
|
}
|
||||||
|
result, msg := getDecodedPath(val, path[1:]...)
|
||||||
|
if msg != "" {
|
||||||
|
return nil, "/" + key + msg
|
||||||
|
}
|
||||||
|
return result, ""
|
||||||
|
}
|
||||||
602
pkg/server/pop3/handler.go
Normal file
602
pkg/server/pop3/handler.go
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State tracks the current mode of our POP3 state machine
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AUTHORIZATION state: the client must now identify and authenticate
|
||||||
|
AUTHORIZATION State = iota
|
||||||
|
// TRANSACTION state: mailbox open, client may now issue commands
|
||||||
|
TRANSACTION
|
||||||
|
// QUIT state: client requests us to end session
|
||||||
|
QUIT
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
switch s {
|
||||||
|
case AUTHORIZATION:
|
||||||
|
return "AUTHORIZATION"
|
||||||
|
case TRANSACTION:
|
||||||
|
return "TRANSACTION"
|
||||||
|
case QUIT:
|
||||||
|
return "QUIT"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands = map[string]bool{
|
||||||
|
"QUIT": true,
|
||||||
|
"STAT": true,
|
||||||
|
"LIST": true,
|
||||||
|
"RETR": true,
|
||||||
|
"DELE": true,
|
||||||
|
"NOOP": true,
|
||||||
|
"RSET": true,
|
||||||
|
"TOP": true,
|
||||||
|
"UIDL": true,
|
||||||
|
"USER": true,
|
||||||
|
"PASS": true,
|
||||||
|
"APOP": true,
|
||||||
|
"CAPA": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session defines an active POP3 session
|
||||||
|
type Session struct {
|
||||||
|
*Server // Reference to the server we belong to.
|
||||||
|
id int // Session ID number.
|
||||||
|
conn net.Conn // Our network connection.
|
||||||
|
remoteHost string // IP address of client.
|
||||||
|
sendError error // Used to bail out of read loop on send error.
|
||||||
|
state State // Current session state.
|
||||||
|
reader *bufio.Reader // Buffered reader for our net conn.
|
||||||
|
user string // Mailbox name.
|
||||||
|
messages []storage.Message // Slice of messages in mailbox.
|
||||||
|
retain []bool // Messages to retain upon UPDATE (true=retain).
|
||||||
|
msgCount int // Number of undeleted messages.
|
||||||
|
logger zerolog.Logger // Session specific logger.
|
||||||
|
debug bool // Print network traffic to stdout.
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession creates a new POP3 session
|
||||||
|
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
return &Session{
|
||||||
|
Server: server,
|
||||||
|
id: id,
|
||||||
|
conn: conn,
|
||||||
|
state: AUTHORIZATION,
|
||||||
|
reader: reader,
|
||||||
|
remoteHost: host,
|
||||||
|
logger: logger,
|
||||||
|
debug: server.config.Debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) String() string {
|
||||||
|
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session flow:
|
||||||
|
* 1. Send initial greeting
|
||||||
|
* 2. Receive cmd
|
||||||
|
* 3. If good cmd, respond, optionally change state
|
||||||
|
* 4. If bad cmd, respond error
|
||||||
|
* 5. Goto 2
|
||||||
|
*/
|
||||||
|
func (s *Server) startSession(id int, conn net.Conn) {
|
||||||
|
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
|
||||||
|
Int("session", id).Logger()
|
||||||
|
logger.Info().Msg("Starting POP3 session")
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Closing connection")
|
||||||
|
}
|
||||||
|
s.wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ssn := NewSession(s, id, conn, logger)
|
||||||
|
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
|
||||||
|
time.Now().Unix(), s.config.Domain))
|
||||||
|
|
||||||
|
// This is our command reading loop
|
||||||
|
for ssn.state != QUIT && ssn.sendError == nil {
|
||||||
|
line, err := ssn.readLine()
|
||||||
|
if err == nil {
|
||||||
|
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||||
|
// Check against valid SMTP commands
|
||||||
|
if cmd == "" {
|
||||||
|
ssn.send("-ERR Speak up")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !commands[cmd] {
|
||||||
|
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
|
||||||
|
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands we handle in any state
|
||||||
|
switch cmd {
|
||||||
|
case "CAPA":
|
||||||
|
// List our capabilities per RFC2449
|
||||||
|
ssn.send("+OK Capability list follows")
|
||||||
|
ssn.send("TOP")
|
||||||
|
ssn.send("USER")
|
||||||
|
ssn.send("UIDL")
|
||||||
|
ssn.send("IMPLEMENTATION Inbucket")
|
||||||
|
ssn.send(".")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to handler for current state
|
||||||
|
switch ssn.state {
|
||||||
|
case AUTHORIZATION:
|
||||||
|
ssn.authorizationHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
case TRANSACTION:
|
||||||
|
ssn.transactionHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
ssn.send("-ERR Syntax error, command garbled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// readLine() returned an error
|
||||||
|
if err == io.EOF {
|
||||||
|
switch ssn.state {
|
||||||
|
case AUTHORIZATION:
|
||||||
|
// EOF is common here
|
||||||
|
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
|
||||||
|
default:
|
||||||
|
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// not an EOF
|
||||||
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
ssn.send("-ERR Idle timeout, bye bye")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssn.send("-ERR Connection error, sorry")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssn.sendError != nil {
|
||||||
|
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
|
||||||
|
}
|
||||||
|
ssn.logger.Info().Msgf("Closing connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTHORIZATION state
|
||||||
|
func (s *Session) authorizationHandler(cmd string, args []string) {
|
||||||
|
switch cmd {
|
||||||
|
case "QUIT":
|
||||||
|
s.send("+OK Goodnight and good luck")
|
||||||
|
s.enterState(QUIT)
|
||||||
|
case "USER":
|
||||||
|
if len(args) > 0 {
|
||||||
|
s.user = args[0]
|
||||||
|
s.send(fmt.Sprintf("+OK Hello %v, welcome to Inbucket", s.user))
|
||||||
|
} else {
|
||||||
|
s.send("-ERR Missing username argument")
|
||||||
|
}
|
||||||
|
case "PASS":
|
||||||
|
if s.user == "" {
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
} else {
|
||||||
|
s.loadMailbox()
|
||||||
|
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
|
||||||
|
s.enterState(TRANSACTION)
|
||||||
|
}
|
||||||
|
case "APOP":
|
||||||
|
if len(args) != 2 {
|
||||||
|
s.logger.Warn().Msgf("Expected two arguments for APOP")
|
||||||
|
s.send("-ERR APOP requires two arguments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.user = args[0]
|
||||||
|
s.loadMailbox()
|
||||||
|
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
|
||||||
|
s.enterState(TRANSACTION)
|
||||||
|
default:
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRANSACTION state
|
||||||
|
func (s *Session) transactionHandler(cmd string, args []string) {
|
||||||
|
switch cmd {
|
||||||
|
case "STAT":
|
||||||
|
if len(args) != 0 {
|
||||||
|
s.logger.Warn().Msgf("STAT got an unexpected argument")
|
||||||
|
s.send("-ERR STAT command must have no arguments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
var size int64
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if s.retain[i] {
|
||||||
|
count++
|
||||||
|
size += msg.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v %v", count, size))
|
||||||
|
case "LIST":
|
||||||
|
if len(args) > 1 {
|
||||||
|
s.logger.Warn().Msgf("LIST command had more than 1 argument")
|
||||||
|
s.send("-ERR LIST command must have zero or one argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("LIST command argument was not an integer")
|
||||||
|
s.send("-ERR LIST command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("LIST command argument was less than 1")
|
||||||
|
s.send("-ERR LIST argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("LIST command argument was greater than number of messages")
|
||||||
|
s.send("-ERR LIST argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.retain[msgNum-1] {
|
||||||
|
s.logger.Warn().Msgf("Client tried to LIST a message it had deleted")
|
||||||
|
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].Size()))
|
||||||
|
} else {
|
||||||
|
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if s.retain[i] {
|
||||||
|
s.send(fmt.Sprintf("%v %v", i+1, msg.Size()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
case "UIDL":
|
||||||
|
if len(args) > 1 {
|
||||||
|
s.logger.Warn().Msgf("UIDL command had more than 1 argument")
|
||||||
|
s.send("-ERR UIDL command must have zero or one argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("UIDL command argument was not an integer")
|
||||||
|
s.send("-ERR UIDL command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("UIDL command argument was less than 1")
|
||||||
|
s.send("-ERR UIDL argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("UIDL command argument was greater than number of messages")
|
||||||
|
s.send("-ERR UIDL argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.retain[msgNum-1] {
|
||||||
|
s.logger.Warn().Msgf("Client tried to UIDL a message it had deleted")
|
||||||
|
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].ID()))
|
||||||
|
} else {
|
||||||
|
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if s.retain[i] {
|
||||||
|
s.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
case "DELE":
|
||||||
|
if len(args) != 1 {
|
||||||
|
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
|
||||||
|
s.send("-ERR DELE command requires a single argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("DELE command argument was not an integer")
|
||||||
|
s.send("-ERR DELE command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("DELE command argument was less than 1")
|
||||||
|
s.send("-ERR DELE argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("DELE command argument was greater than number of messages")
|
||||||
|
s.send("-ERR DELE argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.retain[msgNum-1] {
|
||||||
|
s.retain[msgNum-1] = false
|
||||||
|
s.msgCount--
|
||||||
|
s.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
|
||||||
|
} else {
|
||||||
|
s.logger.Warn().Msgf("Client tried to DELE an already deleted message")
|
||||||
|
s.send(fmt.Sprintf("-ERR Message %v has already been deleted", msgNum))
|
||||||
|
}
|
||||||
|
case "RETR":
|
||||||
|
if len(args) != 1 {
|
||||||
|
s.logger.Warn().Msgf("RETR command had invalid number of arguments")
|
||||||
|
s.send("-ERR RETR command requires a single argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("RETR command argument was not an integer")
|
||||||
|
s.send("-ERR RETR command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("RETR command argument was less than 1")
|
||||||
|
s.send("-ERR RETR argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("RETR command argument was greater than number of messages")
|
||||||
|
s.send("-ERR RETR argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("+OK %v bytes follows", s.messages[msgNum-1].Size()))
|
||||||
|
s.sendMessage(s.messages[msgNum-1])
|
||||||
|
case "TOP":
|
||||||
|
if len(args) != 2 {
|
||||||
|
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
|
||||||
|
s.send("-ERR TOP command requires two arguments")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgNum, err := strconv.ParseInt(args[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("TOP command first argument was not an integer")
|
||||||
|
s.send("-ERR TOP command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgNum < 1 {
|
||||||
|
s.logger.Warn().Msgf("TOP command first argument was less than 1")
|
||||||
|
s.send("-ERR TOP first argument must be greater than 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(msgNum) > len(s.messages) {
|
||||||
|
s.logger.Warn().Msgf("TOP command first argument was greater than number of messages")
|
||||||
|
s.send("-ERR TOP first argument must not exceed the number of messages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines int64
|
||||||
|
lines, err = strconv.ParseInt(args[1], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn().Msgf("TOP command second argument was not an integer")
|
||||||
|
s.send("-ERR TOP command requires an integer argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lines < 0 {
|
||||||
|
s.logger.Warn().Msgf("TOP command second argument was negative")
|
||||||
|
s.send("-ERR TOP second argument must be non-negative")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send("+OK Top of message follows")
|
||||||
|
s.sendMessageTop(s.messages[msgNum-1], int(lines))
|
||||||
|
case "QUIT":
|
||||||
|
s.send("+OK We will process your deletes")
|
||||||
|
s.processDeletes()
|
||||||
|
s.enterState(QUIT)
|
||||||
|
case "NOOP":
|
||||||
|
s.send("+OK I have sucessfully done nothing")
|
||||||
|
case "RSET":
|
||||||
|
// Reset session, don't actually delete anything I told you to
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on RSET request")
|
||||||
|
s.reset()
|
||||||
|
s.send("+OK Session reset")
|
||||||
|
default:
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the contents of the message to the client
|
||||||
|
func (s *Session) sendMessage(msg storage.Message) {
|
||||||
|
reader, err := msg.Source()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to close message: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Lines starting with . must be prefixed with another .
|
||||||
|
if strings.HasPrefix(line, ".") {
|
||||||
|
line = "." + line
|
||||||
|
}
|
||||||
|
s.send(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = scanner.Err(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send(".")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the headers plus the top N lines to the client
|
||||||
|
func (s *Session) sendMessageTop(msg storage.Message, lineCount int) {
|
||||||
|
reader, err := msg.Source()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to close message: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
inBody := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Lines starting with . must be prefixed with another .
|
||||||
|
if strings.HasPrefix(line, ".") {
|
||||||
|
line = "." + line
|
||||||
|
}
|
||||||
|
if inBody {
|
||||||
|
// Check if we need to send anymore lines
|
||||||
|
if lineCount < 1 {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
lineCount--
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if line == "" {
|
||||||
|
// We've hit the end of the header
|
||||||
|
inBody = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = scanner.Err(); err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to read message for RETR command")
|
||||||
|
s.send(".")
|
||||||
|
s.send("-ERR Failed to RETR that message, internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.send(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the users mailbox
|
||||||
|
func (s *Session) loadMailbox() {
|
||||||
|
s.logger = s.logger.With().Str("mailbox", s.user).Logger()
|
||||||
|
m, err := s.store.GetMessages(s.user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("Failed to load messages for %v: %v", s.user, err)
|
||||||
|
}
|
||||||
|
s.messages = m
|
||||||
|
s.retainAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset retain flag to true for all messages
|
||||||
|
func (s *Session) retainAll() {
|
||||||
|
s.retain = make([]bool, len(s.messages))
|
||||||
|
for i := range s.retain {
|
||||||
|
s.retain[i] = true
|
||||||
|
}
|
||||||
|
s.msgCount = len(s.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This would be considered the "UPDATE" state in the RFC, but it does not fit
|
||||||
|
// with our state-machine design here, since no commands are accepted - it just
|
||||||
|
// indicates that the session was closed cleanly and that deletes should be
|
||||||
|
// processed.
|
||||||
|
func (s *Session) processDeletes() {
|
||||||
|
s.logger.Info().Msgf("Processing deletes")
|
||||||
|
for i, msg := range s.messages {
|
||||||
|
if !s.retain[i] {
|
||||||
|
s.logger.Debug().Str("id", msg.ID()).Msg("Deleting message")
|
||||||
|
if err := s.store.RemoveMessage(s.user, msg.ID()); err != nil {
|
||||||
|
s.logger.Warn().Str("id", msg.ID()).Err(err).Msg("Error deleting message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) enterState(state State) {
|
||||||
|
s.state = state
|
||||||
|
s.logger.Debug().Msgf("Entering state %v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDeadline calculates the next read or write deadline based on configured timeout.
|
||||||
|
func (s *Session) nextDeadline() time.Time {
|
||||||
|
return time.Now().Add(s.config.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send requested message, store errors in Session.sendError
|
||||||
|
func (s *Session) send(msg string) {
|
||||||
|
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d > %v\n", s.id, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads a line of input
|
||||||
|
func (s *Session) readLine() (line string, err error) {
|
||||||
|
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line, err = s.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if line == "" {
|
||||||
|
return "", nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
words := strings.Split(line, " ")
|
||||||
|
return strings.ToUpper(words[0]), words[1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reset() {
|
||||||
|
s.retainAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ooSeq(cmd string) {
|
||||||
|
s.send(fmt.Sprintf("-ERR Command %v is out of sequence", cmd))
|
||||||
|
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
|
||||||
|
}
|
||||||
117
pkg/server/pop3/listener.go
Normal file
117
pkg/server/pop3/listener.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server defines an instance of the POP3 server.
|
||||||
|
type Server struct {
|
||||||
|
config config.POP3 // POP3 configuration.
|
||||||
|
store storage.Store // Mail store.
|
||||||
|
listener net.Listener // TCP listener.
|
||||||
|
globalShutdown chan bool // Inbucket shutdown signal.
|
||||||
|
wg *sync.WaitGroup // Waitgroup tracking sessions.
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Server struct.
|
||||||
|
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
|
||||||
|
return &Server{
|
||||||
|
config: pop3Config,
|
||||||
|
store: store,
|
||||||
|
globalShutdown: shutdownChan,
|
||||||
|
wg: new(sync.WaitGroup),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server and listen for connections
|
||||||
|
func (s *Server) Start(ctx context.Context) {
|
||||||
|
slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger()
|
||||||
|
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
|
||||||
|
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Listener go routine.
|
||||||
|
go s.serve(ctx)
|
||||||
|
// Wait for shutdown.
|
||||||
|
select {
|
||||||
|
case _ = <-ctx.Done():
|
||||||
|
}
|
||||||
|
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
|
||||||
|
slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
|
||||||
|
// Closing the listener will cause the serve() go routine to exit.
|
||||||
|
if err := s.listener.Close(); err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to close POP3 listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is the listen/accept loop.
|
||||||
|
func (s *Server) serve(ctx context.Context) {
|
||||||
|
// Handle incoming connections.
|
||||||
|
var tempDelay time.Duration
|
||||||
|
for sid := 1; ; sid++ {
|
||||||
|
if conn, err := s.listener.Accept(); err != nil {
|
||||||
|
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||||
|
// Temporary error, sleep for a bit and try again.
|
||||||
|
if tempDelay == 0 {
|
||||||
|
tempDelay = 5 * time.Millisecond
|
||||||
|
} else {
|
||||||
|
tempDelay *= 2
|
||||||
|
}
|
||||||
|
if max := 1 * time.Second; tempDelay > max {
|
||||||
|
tempDelay = max
|
||||||
|
}
|
||||||
|
log.Error().Str("module", "pop3").Err(err).
|
||||||
|
Msgf("POP3 accept error; retrying in %v", tempDelay)
|
||||||
|
time.Sleep(tempDelay)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// Permanent error.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// POP3 is shutting down.
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Something went wrong.
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tempDelay = 0
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.startSession(sid, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) emergencyShutdown() {
|
||||||
|
// Shutdown Inbucket
|
||||||
|
select {
|
||||||
|
case _ = <-s.globalShutdown:
|
||||||
|
default:
|
||||||
|
close(s.globalShutdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain causes the caller to block until all active POP3 sessions have finished
|
||||||
|
func (s *Server) Drain() {
|
||||||
|
// Wait for sessions to close
|
||||||
|
s.wg.Wait()
|
||||||
|
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
|
||||||
|
}
|
||||||
576
pkg/server/smtp/handler.go
Normal file
576
pkg/server/smtp/handler.go
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/textproto"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State tracks the current mode of our SMTP state machine.
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// timeStampFormat to use in Received header.
|
||||||
|
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||||
|
|
||||||
|
// GREET State: Waiting for HELO
|
||||||
|
GREET State = iota
|
||||||
|
// READY State: Got HELO, waiting for MAIL
|
||||||
|
READY
|
||||||
|
// MAIL State: Got MAIL, accepting RCPTs
|
||||||
|
MAIL
|
||||||
|
// DATA State: Got DATA, waiting for "."
|
||||||
|
DATA
|
||||||
|
// QUIT State: Client requested end of session
|
||||||
|
QUIT
|
||||||
|
)
|
||||||
|
|
||||||
|
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
|
||||||
|
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||||
|
// (?:) is non-grouping sub-match
|
||||||
|
var fromRegex = regexp.MustCompile(
|
||||||
|
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
switch s {
|
||||||
|
case GREET:
|
||||||
|
return "GREET"
|
||||||
|
case READY:
|
||||||
|
return "READY"
|
||||||
|
case MAIL:
|
||||||
|
return "MAIL"
|
||||||
|
case DATA:
|
||||||
|
return "DATA"
|
||||||
|
case QUIT:
|
||||||
|
return "QUIT"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var commands = map[string]bool{
|
||||||
|
"HELO": true,
|
||||||
|
"EHLO": true,
|
||||||
|
"MAIL": true,
|
||||||
|
"RCPT": true,
|
||||||
|
"DATA": true,
|
||||||
|
"RSET": true,
|
||||||
|
"SEND": true,
|
||||||
|
"SOML": true,
|
||||||
|
"SAML": true,
|
||||||
|
"VRFY": true,
|
||||||
|
"EXPN": true,
|
||||||
|
"HELP": true,
|
||||||
|
"NOOP": true,
|
||||||
|
"QUIT": true,
|
||||||
|
"TURN": true,
|
||||||
|
"STARTTLS": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session holds the state of an SMTP session
|
||||||
|
type Session struct {
|
||||||
|
*Server // Server this session belongs to.
|
||||||
|
id int // Session ID.
|
||||||
|
conn net.Conn // TCP connection.
|
||||||
|
remoteDomain string // Remote domain from HELO command.
|
||||||
|
remoteHost string // Remote host.
|
||||||
|
sendError error // Last network send error.
|
||||||
|
state State // Session state machine.
|
||||||
|
reader *bufio.Reader // Buffered reading for TCP conn.
|
||||||
|
from string // Sender from MAIL command.
|
||||||
|
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||||
|
logger zerolog.Logger // Session specific logger.
|
||||||
|
debug bool // Print network traffic to stdout.
|
||||||
|
tlsState *tls.ConnectionState
|
||||||
|
text *textproto.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession creates a new Session for the given connection
|
||||||
|
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
|
||||||
|
return &Session{
|
||||||
|
Server: server,
|
||||||
|
id: id,
|
||||||
|
conn: conn,
|
||||||
|
state: GREET,
|
||||||
|
reader: reader,
|
||||||
|
remoteHost: host,
|
||||||
|
recipients: make([]*policy.Recipient, 0),
|
||||||
|
logger: logger,
|
||||||
|
debug: server.config.Debug,
|
||||||
|
text: textproto.NewConn(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) String() string {
|
||||||
|
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session flow:
|
||||||
|
* 1. Send initial greeting
|
||||||
|
* 2. Receive cmd
|
||||||
|
* 3. If good cmd, respond, optionally change state
|
||||||
|
* 4. If bad cmd, respond error
|
||||||
|
* 5. Goto 2
|
||||||
|
*/
|
||||||
|
func (s *Server) startSession(id int, conn net.Conn) {
|
||||||
|
logger := log.Hook(logHook{}).With().
|
||||||
|
Str("module", "smtp").
|
||||||
|
Str("remote", conn.RemoteAddr().String()).
|
||||||
|
Int("session", id).Logger()
|
||||||
|
logger.Info().Msg("Starting SMTP session")
|
||||||
|
expConnectsCurrent.Add(1)
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Close(); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Closing connection")
|
||||||
|
}
|
||||||
|
s.wg.Done()
|
||||||
|
expConnectsCurrent.Add(-1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ssn := NewSession(s, id, conn, logger)
|
||||||
|
ssn.greet()
|
||||||
|
|
||||||
|
// This is our command reading loop
|
||||||
|
for ssn.state != QUIT && ssn.sendError == nil {
|
||||||
|
if ssn.state == DATA {
|
||||||
|
// Special case, does not use SMTP command format
|
||||||
|
ssn.dataHandler()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line, err := ssn.readLine()
|
||||||
|
if err == nil {
|
||||||
|
if cmd, arg, ok := ssn.parseCmd(line); ok {
|
||||||
|
// Check against valid SMTP commands
|
||||||
|
if cmd == "" {
|
||||||
|
ssn.send("500 Speak up")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !commands[cmd] {
|
||||||
|
ssn.send(fmt.Sprintf("500 Syntax error, %v command unrecognized", cmd))
|
||||||
|
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands we handle in any state
|
||||||
|
switch cmd {
|
||||||
|
case "SEND", "SOML", "SAML", "EXPN", "HELP", "TURN":
|
||||||
|
// These commands are not implemented in any state
|
||||||
|
ssn.send(fmt.Sprintf("502 %v command not implemented", cmd))
|
||||||
|
ssn.logger.Warn().Msgf("Command %v not implemented by Inbucket", cmd)
|
||||||
|
continue
|
||||||
|
case "VRFY":
|
||||||
|
ssn.send("252 Cannot VRFY user, but will accept message")
|
||||||
|
continue
|
||||||
|
case "NOOP":
|
||||||
|
ssn.send("250 I have sucessfully done nothing")
|
||||||
|
continue
|
||||||
|
case "RSET":
|
||||||
|
// Reset session
|
||||||
|
ssn.logger.Debug().Msgf("Resetting session state on RSET request")
|
||||||
|
ssn.reset()
|
||||||
|
ssn.send("250 Session reset")
|
||||||
|
continue
|
||||||
|
case "QUIT":
|
||||||
|
ssn.send("221 Goodnight and good luck")
|
||||||
|
ssn.enterState(QUIT)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to handler for current state
|
||||||
|
switch ssn.state {
|
||||||
|
case GREET:
|
||||||
|
ssn.greetHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
case READY:
|
||||||
|
ssn.readyHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
case MAIL:
|
||||||
|
ssn.mailHandler(cmd, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
ssn.send("500 Syntax error, command garbled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// readLine() returned an error
|
||||||
|
if err == io.EOF {
|
||||||
|
switch ssn.state {
|
||||||
|
case GREET, READY:
|
||||||
|
// EOF is common here
|
||||||
|
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
|
||||||
|
default:
|
||||||
|
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// not an EOF
|
||||||
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
ssn.send("221 Idle timeout, bye bye")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssn.send("221 Connection error, sorry")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssn.sendError != nil {
|
||||||
|
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
|
||||||
|
}
|
||||||
|
ssn.logger.Info().Msgf("Closing connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GREET state -> waiting for HELO
|
||||||
|
func (s *Session) greetHandler(cmd string, arg string) {
|
||||||
|
const readyBanner = "Great, let's get this show on the road"
|
||||||
|
switch cmd {
|
||||||
|
case "HELO":
|
||||||
|
domain, err := parseHelloArgument(arg)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Domain/address argument required for HELO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.remoteDomain = domain
|
||||||
|
s.send("250 " + readyBanner)
|
||||||
|
s.enterState(READY)
|
||||||
|
case "EHLO":
|
||||||
|
domain, err := parseHelloArgument(arg)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Domain/address argument required for EHLO")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.remoteDomain = domain
|
||||||
|
// features before SIZE per RFC
|
||||||
|
s.send("250-" + readyBanner)
|
||||||
|
s.send("250-8BITMIME")
|
||||||
|
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||||
|
s.send("250-STARTTLS")
|
||||||
|
}
|
||||||
|
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
||||||
|
s.enterState(READY)
|
||||||
|
default:
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHelloArgument(arg string) (string, error) {
|
||||||
|
domain := arg
|
||||||
|
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
|
||||||
|
domain = arg[:idx]
|
||||||
|
}
|
||||||
|
if domain == "" {
|
||||||
|
return "", fmt.Errorf("Invalid domain")
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// READY state -> waiting for MAIL
|
||||||
|
func (s *Session) readyHandler(cmd string, arg string) {
|
||||||
|
if cmd == "STARTTLS" {
|
||||||
|
if !s.Server.config.TLSEnabled {
|
||||||
|
// invalid command since unconfigured
|
||||||
|
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||||
|
s.send("454 TLS unavailable on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.tlsState != nil {
|
||||||
|
// tls state previously valid
|
||||||
|
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
||||||
|
s.send("454 A TLS session already agreed upon.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Debug().Msg("Initiating TLS context.")
|
||||||
|
s.send("220 STARTTLS")
|
||||||
|
// start tls connection handshake
|
||||||
|
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||||
|
s.conn = tlsConn
|
||||||
|
s.text = textproto.NewConn(s.conn)
|
||||||
|
s.tlsState = new(tls.ConnectionState)
|
||||||
|
*s.tlsState = tlsConn.ConnectionState()
|
||||||
|
s.enterState(GREET)
|
||||||
|
} else if cmd == "MAIL" {
|
||||||
|
// Capture group 1: from address. 2: optional params.
|
||||||
|
m := fromRegex.FindStringSubmatch(arg)
|
||||||
|
if m == nil {
|
||||||
|
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
|
||||||
|
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := m[1]
|
||||||
|
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||||
|
s.send("501 Bad sender address syntax")
|
||||||
|
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if from == "" {
|
||||||
|
from = "unspecified"
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is where the client may put BODY=8BITMIME, but we already
|
||||||
|
// read the DATA as bytes, so it does not effect our processing.
|
||||||
|
if m[2] != "" {
|
||||||
|
args, ok := s.parseArgs(m[2])
|
||||||
|
if !ok {
|
||||||
|
s.send("501 Unable to parse MAIL ESMTP parameters")
|
||||||
|
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if args["SIZE"] != "" {
|
||||||
|
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Unable to parse SIZE as an integer")
|
||||||
|
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if int(size) > s.config.MaxMessageBytes {
|
||||||
|
s.send("552 Max message size exceeded")
|
||||||
|
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.from = from
|
||||||
|
s.logger.Info().Msgf("Mail from: %v", from)
|
||||||
|
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||||
|
s.enterState(MAIL)
|
||||||
|
} else if cmd == "EHLO" {
|
||||||
|
// Reset session
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||||
|
s.reset()
|
||||||
|
s.send("250 Session reset")
|
||||||
|
} else {
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAIL state -> waiting for RCPTs followed by DATA
|
||||||
|
func (s *Session) mailHandler(cmd string, arg string) {
|
||||||
|
switch cmd {
|
||||||
|
case "RCPT":
|
||||||
|
if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") {
|
||||||
|
s.send("501 Was expecting RCPT arg syntax of TO:<address>")
|
||||||
|
s.logger.Warn().Msgf("Bad RCPT argument: %q", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := strings.Trim(arg[3:], "<> ")
|
||||||
|
recip, err := s.addrPolicy.NewRecipient(addr)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Bad recipient address syntax")
|
||||||
|
s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !recip.ShouldAccept() {
|
||||||
|
s.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain")
|
||||||
|
s.send("550 Relay not permitted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(s.recipients) >= s.config.MaxRecipients {
|
||||||
|
s.logger.Warn().Msgf("Limit of %v recipients exceeded", s.config.MaxRecipients)
|
||||||
|
s.send(fmt.Sprintf("552 Limit of %v recipients exceeded", s.config.MaxRecipients))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recipients = append(s.recipients, recip)
|
||||||
|
s.logger.Debug().Str("to", addr).Msg("Recipient added")
|
||||||
|
s.send(fmt.Sprintf("250 I'll make sure <%v> gets this", addr))
|
||||||
|
return
|
||||||
|
case "DATA":
|
||||||
|
if arg != "" {
|
||||||
|
s.send("501 DATA command should not have any arguments")
|
||||||
|
s.logger.Warn().Msgf("Got unexpected args on DATA: %q", arg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(s.recipients) == 0 {
|
||||||
|
// DATA out of sequence
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.enterState(DATA)
|
||||||
|
return
|
||||||
|
case "EHLO":
|
||||||
|
// Reset session
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||||
|
s.reset()
|
||||||
|
s.send("250 Session reset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ooSeq(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
func (s *Session) dataHandler() {
|
||||||
|
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
||||||
|
msgBuf, err := s.readDataBlock()
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
if netErr.Timeout() {
|
||||||
|
s.send("221 Idle timeout, bye bye")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||||
|
s.enterState(QUIT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mailData := bytes.NewBuffer(msgBuf)
|
||||||
|
|
||||||
|
// Mail data complete.
|
||||||
|
tstamp := time.Now().Format(timeStampFormat)
|
||||||
|
for _, recip := range s.recipients {
|
||||||
|
if recip.ShouldStore() {
|
||||||
|
// Generate Received header.
|
||||||
|
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||||
|
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||||
|
tstamp)
|
||||||
|
|
||||||
|
// Deliver message.
|
||||||
|
_, err := s.manager.Deliver(
|
||||||
|
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||||
|
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||||
|
s.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expReceivedTotal.Add(1)
|
||||||
|
}
|
||||||
|
s.send("250 Mail accepted for delivery")
|
||||||
|
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
|
||||||
|
s.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) enterState(state State) {
|
||||||
|
s.state = state
|
||||||
|
s.logger.Debug().Msgf("Entering state %v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) greet() {
|
||||||
|
s.send(fmt.Sprintf("220 %v Inbucket SMTP ready", s.config.Domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDeadline calculates the next read or write deadline based on configured timeout.
|
||||||
|
func (s *Session) nextDeadline() time.Time {
|
||||||
|
return time.Now().Add(s.config.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send requested message, store errors in Session.sendError
|
||||||
|
func (s *Session) send(msg string) {
|
||||||
|
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.text.PrintfLine("%s", msg); err != nil {
|
||||||
|
s.sendError = err
|
||||||
|
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d > %v\n", s.id, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDataBlock reads message DATA until `.` using the textproto pkg.
|
||||||
|
func (s *Session) readDataBlock() ([]byte, error) {
|
||||||
|
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := s.text.ReadDotBytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
|
||||||
|
}
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLine reads a line of input respecting deadlines.
|
||||||
|
func (s *Session) readLine() (line string, err error) {
|
||||||
|
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line, err = s.text.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
l := len(line)
|
||||||
|
switch {
|
||||||
|
case l == 0:
|
||||||
|
return "", "", true
|
||||||
|
case l < 4:
|
||||||
|
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||||
|
return "", "", false
|
||||||
|
case l == 4 || l == 8:
|
||||||
|
return strings.ToUpper(line), "", true
|
||||||
|
case l == 5:
|
||||||
|
// Too long to be only command, too short to have args
|
||||||
|
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we made it here, command is long enough to have args
|
||||||
|
if line[4] != ' ' {
|
||||||
|
// There wasn't a space after the command?
|
||||||
|
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'm not sure if we should trim the args or not, but we will for now
|
||||||
|
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArgs takes the arguments proceeding a command and files them
|
||||||
|
// into a map[string]string after uppercasing each key. Sample arg
|
||||||
|
// string:
|
||||||
|
// " BODY=8BITMIME SIZE=1024"
|
||||||
|
// The leading space is mandatory.
|
||||||
|
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||||
|
args = make(map[string]string)
|
||||||
|
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
||||||
|
pm := re.FindAllStringSubmatch(arg, -1)
|
||||||
|
if pm == nil {
|
||||||
|
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, m := range pm {
|
||||||
|
args[strings.ToUpper(m[1])] = m[2]
|
||||||
|
}
|
||||||
|
s.logger.Debug().Msgf("ESMTP params: %v", args)
|
||||||
|
return args, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reset() {
|
||||||
|
s.enterState(READY)
|
||||||
|
s.from = ""
|
||||||
|
s.recipients = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ooSeq(cmd string) {
|
||||||
|
s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
|
||||||
|
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
package smtpd
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"io"
|
"io"
|
||||||
//"io/ioutil"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scriptStep struct {
|
type scriptStep struct {
|
||||||
@@ -21,18 +26,14 @@ type scriptStep struct {
|
|||||||
|
|
||||||
// Test commands in GREET state
|
// Test commands in GREET state
|
||||||
func TestGreetState(t *testing.T) {
|
func TestGreetState(t *testing.T) {
|
||||||
// Setup mock objects
|
ds := test.NewStore()
|
||||||
mds := &MockDataStore{}
|
server, logbuf, teardown := setupSMTPServer(ds)
|
||||||
mb1 := &MockMailbox{}
|
defer teardown()
|
||||||
mds.On("MailboxFor").Return(mb1, nil)
|
|
||||||
|
|
||||||
server, logbuf := setupSmtpServer(mds)
|
|
||||||
defer teardownSmtpServer(server)
|
|
||||||
|
|
||||||
var script []scriptStep
|
|
||||||
|
|
||||||
// Test out some mangled HELOs
|
// Test out some mangled HELOs
|
||||||
script = []scriptStep{
|
script := []scriptStep{
|
||||||
|
{"HELO", 501},
|
||||||
|
{"EHLO", 501},
|
||||||
{"HELLO", 500},
|
{"HELLO", 500},
|
||||||
{"HELL", 500},
|
{"HELL", 500},
|
||||||
{"hello", 500},
|
{"hello", 500},
|
||||||
@@ -43,9 +44,6 @@ func TestGreetState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Valid HELOs
|
// Valid HELOs
|
||||||
if err := playSession(t, server, []scriptStep{{"HELO", 250}}); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
|
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
@@ -55,29 +53,69 @@ func TestGreetState(t *testing.T) {
|
|||||||
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
|
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid EHLOs
|
||||||
|
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
if t.Failed() {
|
if t.Failed() {
|
||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
// Dump buffered log data if there was a failure
|
// Dump buffered log data if there was a failure
|
||||||
io.Copy(os.Stderr, logbuf)
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test commands in READY state
|
||||||
|
func TestEmptyEnvelope(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server, logbuf, teardown := setupSMTPServer(ds)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// Test out some empty envelope without blanks
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM:<>", 250},
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test out some empty envelope with blanks
|
||||||
|
script = []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM: <>", 250},
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
t.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test commands in READY state
|
// Test commands in READY state
|
||||||
func TestReadyState(t *testing.T) {
|
func TestReadyState(t *testing.T) {
|
||||||
// Setup mock objects
|
ds := test.NewStore()
|
||||||
mds := &MockDataStore{}
|
server, logbuf, teardown := setupSMTPServer(ds)
|
||||||
mb1 := &MockMailbox{}
|
defer teardown()
|
||||||
mds.On("MailboxFor").Return(mb1, nil)
|
|
||||||
|
|
||||||
server, logbuf := setupSmtpServer(mds)
|
|
||||||
defer teardownSmtpServer(server)
|
|
||||||
|
|
||||||
var script []scriptStep
|
|
||||||
|
|
||||||
// Test out some mangled READY commands
|
// Test out some mangled READY commands
|
||||||
script = []scriptStep{
|
script := []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"FOOB", 500},
|
{"FOOB", 500},
|
||||||
{"HELO", 503},
|
{"HELO", 503},
|
||||||
@@ -125,27 +163,18 @@ func TestReadyState(t *testing.T) {
|
|||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
// Dump buffered log data if there was a failure
|
// Dump buffered log data if there was a failure
|
||||||
io.Copy(os.Stderr, logbuf)
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test commands in MAIL state
|
// Test commands in MAIL state
|
||||||
func TestMailState(t *testing.T) {
|
func TestMailState(t *testing.T) {
|
||||||
// Setup mock objects
|
mds := test.NewStore()
|
||||||
mds := &MockDataStore{}
|
server, logbuf, teardown := setupSMTPServer(mds)
|
||||||
mb1 := &MockMailbox{}
|
defer teardown()
|
||||||
msg1 := &MockMessage{}
|
|
||||||
mds.On("MailboxFor").Return(mb1, nil)
|
|
||||||
mb1.On("NewMessage").Return(msg1)
|
|
||||||
msg1.On("Close").Return(nil)
|
|
||||||
|
|
||||||
server, logbuf := setupSmtpServer(mds)
|
|
||||||
defer teardownSmtpServer(server)
|
|
||||||
|
|
||||||
var script []scriptStep
|
|
||||||
|
|
||||||
// Test out some mangled READY commands
|
// Test out some mangled READY commands
|
||||||
script = []scriptStep{
|
script := []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"MAIL FROM:<john@gmail.com>", 250},
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
{"FOOB", 500},
|
{"FOOB", 500},
|
||||||
@@ -169,13 +198,11 @@ func TestMailState(t *testing.T) {
|
|||||||
{"RCPT TO:<u1@gmail.com>", 250},
|
{"RCPT TO:<u1@gmail.com>", 250},
|
||||||
{"RCPT TO: <u2@gmail.com>", 250},
|
{"RCPT TO: <u2@gmail.com>", 250},
|
||||||
{"RCPT TO:u3@gmail.com", 250},
|
{"RCPT TO:u3@gmail.com", 250},
|
||||||
|
{"RCPT TO:u3@deny.com", 550},
|
||||||
{"RCPT TO: u4@gmail.com", 250},
|
{"RCPT TO: u4@gmail.com", 250},
|
||||||
{"RSET", 250},
|
{"RSET", 250},
|
||||||
{"MAIL FROM:<john@gmail.com>", 250},
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
{"RCPT TO:<user\\@internal@external.com", 250},
|
{`RCPT TO:<"first/last"@host.com`, 250},
|
||||||
{"RCPT TO:<\"first last\"@host.com", 250},
|
|
||||||
{"RCPT TO:<user\\>name@host.com>", 250},
|
|
||||||
{"RCPT TO:<\"user>name\"@host.com>", 250},
|
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
@@ -208,6 +235,19 @@ func TestMailState(t *testing.T) {
|
|||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test late EHLO, similar to RSET
|
||||||
|
script = []scriptStep{
|
||||||
|
{"EHLO localhost", 250},
|
||||||
|
{"EHLO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
{"RCPT TO:<u1@gmail.com>", 250},
|
||||||
|
{"EHLO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test RSET
|
// Test RSET
|
||||||
script = []scriptStep{
|
script = []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
@@ -235,28 +275,20 @@ func TestMailState(t *testing.T) {
|
|||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
// Dump buffered log data if there was a failure
|
// Dump buffered log data if there was a failure
|
||||||
io.Copy(os.Stderr, logbuf)
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test commands in DATA state
|
// Test commands in DATA state
|
||||||
func TestDataState(t *testing.T) {
|
func TestDataState(t *testing.T) {
|
||||||
// Setup mock objects
|
mds := test.NewStore()
|
||||||
mds := &MockDataStore{}
|
server, logbuf, teardown := setupSMTPServer(mds)
|
||||||
mb1 := &MockMailbox{}
|
defer teardown()
|
||||||
msg1 := &MockMessage{}
|
|
||||||
mds.On("MailboxFor").Return(mb1, nil)
|
|
||||||
mb1.On("NewMessage").Return(msg1)
|
|
||||||
msg1.On("Close").Return(nil)
|
|
||||||
|
|
||||||
server, logbuf := setupSmtpServer(mds)
|
|
||||||
defer teardownSmtpServer(server)
|
|
||||||
|
|
||||||
var script []scriptStep
|
var script []scriptStep
|
||||||
pipe := setupSmtpSession(server)
|
pipe := setupSMTPSession(server)
|
||||||
c := textproto.NewConn(pipe)
|
c := textproto.NewConn(pipe)
|
||||||
|
|
||||||
// Get us into DATA state
|
|
||||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
t.Errorf("Expected a 220 greeting, got %v", code)
|
t.Errorf("Expected a 220 greeting, got %v", code)
|
||||||
}
|
}
|
||||||
@@ -277,8 +309,35 @@ Subject: test
|
|||||||
Hi!
|
Hi!
|
||||||
`
|
`
|
||||||
dw := c.DotWriter()
|
dw := c.DotWriter()
|
||||||
io.WriteString(dw, body)
|
_, _ = io.WriteString(dw, body)
|
||||||
dw.Close()
|
_ = dw.Close()
|
||||||
|
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||||
|
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with no useful headers.
|
||||||
|
pipe = setupSMTPSession(server)
|
||||||
|
c = textproto.NewConn(pipe)
|
||||||
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
|
t.Errorf("Expected a 220 greeting, got %v", code)
|
||||||
|
}
|
||||||
|
script = []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
{"RCPT TO:<u1@gmail.com>", 250},
|
||||||
|
{"DATA", 354},
|
||||||
|
}
|
||||||
|
if err := playScriptAgainst(t, c, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
// Send a message
|
||||||
|
body = `X-Useless-Header: true
|
||||||
|
|
||||||
|
Hi! Can you still deliver this?
|
||||||
|
`
|
||||||
|
dw = c.DotWriter()
|
||||||
|
_, _ = io.WriteString(dw, body)
|
||||||
|
_ = dw.Close()
|
||||||
if code, _, err := c.ReadCodeLine(250); err != nil {
|
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||||
}
|
}
|
||||||
@@ -287,13 +346,13 @@ Hi!
|
|||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
// Dump buffered log data if there was a failure
|
// Dump buffered log data if there was a failure
|
||||||
io.Copy(os.Stderr, logbuf)
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// playSession creates a new session, reads the greeting and then plays the script
|
// playSession creates a new session, reads the greeting and then plays the script
|
||||||
func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
||||||
pipe := setupSmtpSession(server)
|
pipe := setupSMTPSession(server)
|
||||||
c := textproto.NewConn(pipe)
|
c := textproto.NewConn(pipe)
|
||||||
|
|
||||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||||
@@ -302,8 +361,10 @@ func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
|||||||
|
|
||||||
err := playScriptAgainst(t, c, script)
|
err := playScriptAgainst(t, c, script)
|
||||||
|
|
||||||
c.Cmd("QUIT")
|
// Not all tests leave the session in a clean state, so the following two
|
||||||
c.ReadCodeLine(221)
|
// calls can fail
|
||||||
|
_, _ = c.Cmd("QUIT")
|
||||||
|
_, _, _ = c.ReadCodeLine(221)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -317,7 +378,7 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.StartResponse(id)
|
c.StartResponse(id)
|
||||||
code, msg, err := c.ReadCodeLine(step.expect)
|
code, msg, err := c.ReadResponse(step.expect)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("Step %d, sent %q, expected %v, got %v: %q",
|
err = fmt.Errorf("Step %d, sent %q, expected %v, got %v: %q",
|
||||||
i, step.send, step.expect, code, msg)
|
i, step.send, step.expect, code, msg)
|
||||||
@@ -341,40 +402,41 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
|||||||
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||||
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||||
|
|
||||||
func setupSmtpServer(ds DataStore) (*Server, *bytes.Buffer) {
|
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
|
||||||
// Test Server Config
|
cfg := &config.Root{
|
||||||
cfg := config.SmtpConfig{
|
MailboxNaming: config.FullNaming,
|
||||||
Ip4address: net.IPv4(127, 0, 0, 1),
|
SMTP: config.SMTP{
|
||||||
Ip4port: 2500,
|
Addr: "127.0.0.1:2500",
|
||||||
Domain: "inbucket.local",
|
Domain: "inbucket.local",
|
||||||
DomainNoStore: "bitbucket.local",
|
MaxRecipients: 5,
|
||||||
MaxRecipients: 5,
|
MaxMessageBytes: 5000,
|
||||||
MaxIdleSeconds: 5,
|
DefaultAccept: true,
|
||||||
MaxMessageBytes: 5000,
|
RejectDomains: []string{"deny.com"},
|
||||||
StoreMessages: true,
|
Timeout: 5,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
// Capture log output.
|
||||||
// Capture log output
|
buf = new(bytes.Buffer)
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
log.SetOutput(buf)
|
log.SetOutput(buf)
|
||||||
|
// Create a server, don't start it.
|
||||||
// Create a server, don't start it
|
shutdownChan := make(chan bool)
|
||||||
return NewSmtpServer(cfg, ds), buf
|
teardown = func() {
|
||||||
|
close(shutdownChan)
|
||||||
|
}
|
||||||
|
addrPolicy := &policy.Addressing{Config: cfg}
|
||||||
|
manager := &message.StoreManager{Store: ds}
|
||||||
|
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
|
||||||
|
return s, buf, teardown
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionNum int
|
var sessionNum int
|
||||||
|
|
||||||
func setupSmtpSession(server *Server) net.Conn {
|
func setupSMTPSession(server *Server) net.Conn {
|
||||||
// Pair of pipes to communicate
|
// Pair of pipes to communicate.
|
||||||
serverConn, clientConn := net.Pipe()
|
serverConn, clientConn := net.Pipe()
|
||||||
// Start the session
|
// Start the session.
|
||||||
server.waitgroup.Add(1)
|
server.wg.Add(1)
|
||||||
sessionNum++
|
sessionNum++
|
||||||
go server.startSession(sessionNum, &mockConn{serverConn})
|
go server.startSession(sessionNum, &mockConn{serverConn})
|
||||||
|
|
||||||
return clientConn
|
return clientConn
|
||||||
}
|
}
|
||||||
|
|
||||||
func teardownSmtpServer(server *Server) {
|
|
||||||
//log.SetOutput(os.Stderr)
|
|
||||||
}
|
|
||||||
186
pkg/server/smtp/listener.go
Normal file
186
pkg/server/smtp/listener.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"expvar"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/metric"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Raw stat collectors
|
||||||
|
expConnectsTotal = new(expvar.Int)
|
||||||
|
expConnectsCurrent = new(expvar.Int)
|
||||||
|
expReceivedTotal = new(expvar.Int)
|
||||||
|
expErrorsTotal = new(expvar.Int)
|
||||||
|
expWarnsTotal = new(expvar.Int)
|
||||||
|
|
||||||
|
// History of certain stats
|
||||||
|
deliveredHist = list.New()
|
||||||
|
connectsHist = list.New()
|
||||||
|
errorsHist = list.New()
|
||||||
|
warnsHist = list.New()
|
||||||
|
|
||||||
|
// History rendered as comma delim string
|
||||||
|
expReceivedHist = new(expvar.String)
|
||||||
|
expConnectsHist = new(expvar.String)
|
||||||
|
expErrorsHist = new(expvar.String)
|
||||||
|
expWarnsHist = new(expvar.String)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m := expvar.NewMap("smtp")
|
||||||
|
m.Set("ConnectsTotal", expConnectsTotal)
|
||||||
|
m.Set("ConnectsHist", expConnectsHist)
|
||||||
|
m.Set("ConnectsCurrent", expConnectsCurrent)
|
||||||
|
m.Set("ReceivedTotal", expReceivedTotal)
|
||||||
|
m.Set("ReceivedHist", expReceivedHist)
|
||||||
|
m.Set("ErrorsTotal", expErrorsTotal)
|
||||||
|
m.Set("ErrorsHist", expErrorsHist)
|
||||||
|
m.Set("WarnsTotal", expWarnsTotal)
|
||||||
|
m.Set("WarnsHist", expWarnsHist)
|
||||||
|
metric.AddTickerFunc(func() {
|
||||||
|
expReceivedHist.Set(metric.Push(deliveredHist, expReceivedTotal))
|
||||||
|
expConnectsHist.Set(metric.Push(connectsHist, expConnectsTotal))
|
||||||
|
expErrorsHist.Set(metric.Push(errorsHist, expErrorsTotal))
|
||||||
|
expWarnsHist.Set(metric.Push(warnsHist, expWarnsTotal))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server holds the configuration and state of our SMTP server.
|
||||||
|
type Server struct {
|
||||||
|
config config.SMTP // SMTP configuration.
|
||||||
|
addrPolicy *policy.Addressing // Address policy.
|
||||||
|
globalShutdown chan bool // Shuts down Inbucket.
|
||||||
|
manager message.Manager // Used to deliver messages.
|
||||||
|
listener net.Listener // Incoming network connections.
|
||||||
|
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||||
|
tlsConfig *tls.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new Server instance with the specificed config.
|
||||||
|
func NewServer(
|
||||||
|
smtpConfig config.SMTP,
|
||||||
|
globalShutdown chan bool,
|
||||||
|
manager message.Manager,
|
||||||
|
apolicy *policy.Addressing,
|
||||||
|
) *Server {
|
||||||
|
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
||||||
|
tlsConfig := &tls.Config{}
|
||||||
|
if smtpConfig.TLSEnabled {
|
||||||
|
var err error
|
||||||
|
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
||||||
|
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
||||||
|
slog.Error().Msg("Disabling STARTTLS support")
|
||||||
|
smtpConfig.TLSEnabled = false
|
||||||
|
} else {
|
||||||
|
slog.Debug().Msg("STARTTLS feature available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
config: smtpConfig,
|
||||||
|
globalShutdown: globalShutdown,
|
||||||
|
manager: manager,
|
||||||
|
addrPolicy: apolicy,
|
||||||
|
wg: new(sync.WaitGroup),
|
||||||
|
tlsConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the listener and handle incoming connections.
|
||||||
|
func (s *Server) Start(ctx context.Context) {
|
||||||
|
slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger()
|
||||||
|
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to build tcp4 address")
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
|
||||||
|
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Listener go routine.
|
||||||
|
go s.serve(ctx)
|
||||||
|
// Wait for shutdown.
|
||||||
|
<-ctx.Done()
|
||||||
|
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
|
||||||
|
slog.Debug().Msg("SMTP shutdown requested, connections will be drained")
|
||||||
|
// Closing the listener will cause the serve() go routine to exit.
|
||||||
|
if err := s.listener.Close(); err != nil {
|
||||||
|
slog.Error().Err(err).Msg("Failed to close SMTP listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is the listen/accept loop.
|
||||||
|
func (s *Server) serve(ctx context.Context) {
|
||||||
|
// Handle incoming connections.
|
||||||
|
var tempDelay time.Duration
|
||||||
|
for sessionID := 1; ; sessionID++ {
|
||||||
|
if conn, err := s.listener.Accept(); err != nil {
|
||||||
|
// There was an error accepting the connection.
|
||||||
|
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||||
|
// Temporary error, sleep for a bit and try again.
|
||||||
|
if tempDelay == 0 {
|
||||||
|
tempDelay = 5 * time.Millisecond
|
||||||
|
} else {
|
||||||
|
tempDelay *= 2
|
||||||
|
}
|
||||||
|
if max := 1 * time.Second; tempDelay > max {
|
||||||
|
tempDelay = max
|
||||||
|
}
|
||||||
|
log.Error().Str("module", "smtp").Err(err).
|
||||||
|
Msgf("SMTP accept error; retrying in %v", tempDelay)
|
||||||
|
time.Sleep(tempDelay)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// Permanent error.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// SMTP is shutting down.
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Something went wrong.
|
||||||
|
s.emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tempDelay = 0
|
||||||
|
expConnectsTotal.Add(1)
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.startSession(sessionID, conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) emergencyShutdown() {
|
||||||
|
// Shutdown Inbucket.
|
||||||
|
select {
|
||||||
|
case <-s.globalShutdown:
|
||||||
|
default:
|
||||||
|
close(s.globalShutdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain causes the caller to block until all active SMTP sessions have finished
|
||||||
|
func (s *Server) Drain() {
|
||||||
|
// Wait for sessions to close.
|
||||||
|
s.wg.Wait()
|
||||||
|
log.Debug().Str("module", "smtp").Str("phase", "shutdown").Msg("SMTP connections have drained")
|
||||||
|
}
|
||||||
15
pkg/server/smtp/loghook.go
Normal file
15
pkg/server/smtp/loghook.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import "github.com/rs/zerolog"
|
||||||
|
|
||||||
|
type logHook struct{}
|
||||||
|
|
||||||
|
// Run implements a zerolog hook that updates the SMTP warning/error expvars.
|
||||||
|
func (h logHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
|
||||||
|
switch level {
|
||||||
|
case zerolog.WarnLevel:
|
||||||
|
expWarnsTotal.Add(1)
|
||||||
|
case zerolog.ErrorLevel:
|
||||||
|
expErrorsTotal.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
pkg/server/web/app_json.go
Normal file
6
pkg/server/web/app_json.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
type jsonAppConfig struct {
|
||||||
|
BasePath string `json:"base-path"`
|
||||||
|
MonitorVisible bool `json:"monitor-visible"`
|
||||||
|
}
|
||||||
58
pkg/server/web/context.go
Normal file
58
pkg/server/web/context.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context is passed into every request handler function
|
||||||
|
// TODO remove redundant web config
|
||||||
|
type Context struct {
|
||||||
|
Vars map[string]string
|
||||||
|
MsgHub *msghub.Hub
|
||||||
|
Manager message.Manager
|
||||||
|
RootConfig *config.Root
|
||||||
|
WebConfig config.Web
|
||||||
|
IsJSON bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the Context (currently does nothing)
|
||||||
|
func (c *Context) Close() {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerMatch returns true if the request header specified by name contains
|
||||||
|
// the specified value. Case is ignored.
|
||||||
|
func headerMatch(req *http.Request, name string, value string) bool {
|
||||||
|
name = http.CanonicalHeaderKey(name)
|
||||||
|
value = strings.ToLower(value)
|
||||||
|
|
||||||
|
if header := req.Header[name]; header != nil {
|
||||||
|
for _, hv := range header {
|
||||||
|
if value == strings.ToLower(hv) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContext returns a Context for the given HTTP Request
|
||||||
|
func NewContext(req *http.Request) (*Context, error) {
|
||||||
|
vars := mux.Vars(req)
|
||||||
|
ctx := &Context{
|
||||||
|
Vars: vars,
|
||||||
|
MsgHub: msgHub,
|
||||||
|
Manager: manager,
|
||||||
|
RootConfig: rootConfig,
|
||||||
|
WebConfig: rootConfig.Web,
|
||||||
|
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||||
|
}
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
104
pkg/server/web/handlers.go
Normal file
104
pkg/server/web/handlers.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a function type that handles an HTTP request in Inbucket.
|
||||||
|
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||||
|
|
||||||
|
// ServeHTTP builds the context and passes onto the real handler.
|
||||||
|
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Create the context.
|
||||||
|
ctx, err := NewContext(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ctx.Close()
|
||||||
|
|
||||||
|
// Run the handler, grab the error, and report it.
|
||||||
|
err = h(w, req, ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||||
|
Msg("Error handling request")
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieHandler injects an HTTP cookie into the response.
|
||||||
|
func cookieHandler(cookie *http.Cookie, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg("Injecting cookie")
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
next.ServeHTTP(w, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileHandler creates a handler that sends the named file regardless of the requested URL.
|
||||||
|
func fileHandler(name string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
f, err := os.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||||
|
Msg("Error opening file")
|
||||||
|
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
d, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||||
|
Msg("Error stating file")
|
||||||
|
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeContent(w, req, d.Name(), d.ModTime(), f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
|
||||||
|
// returning specified statusCode to the client.
|
||||||
|
func noMatchHandler(statusCode int, message string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Warn().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg(message)
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestLoggingWrapper returns middleware that logs client requests.
|
||||||
|
func requestLoggingWrapper(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||||
|
next.ServeHTTP(w, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
|
||||||
|
func spaTemplateHandler(tmpl *template.Template, basePath string,
|
||||||
|
webConfig config.Web) http.Handler {
|
||||||
|
tmplData := struct {
|
||||||
|
BasePath string
|
||||||
|
}{
|
||||||
|
BasePath: basePath,
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
err := tmpl.Execute(w, tmplData)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
|
||||||
|
Msg("Error rendering SPA index template")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
26
pkg/server/web/helpers.go
Normal file
26
pkg/server/web/helpers.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
||||||
|
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
||||||
|
|
||||||
|
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||||
|
// HTML display
|
||||||
|
func TextToHTML(text string) string {
|
||||||
|
text = html.EscapeString(text)
|
||||||
|
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||||
|
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||||
|
return replacer.Replace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapURL wraps a <a href> tag around the provided URL
|
||||||
|
func WrapURL(url string) string {
|
||||||
|
unescaped := strings.Replace(url, "&", "&", -1)
|
||||||
|
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
|
||||||
|
}
|
||||||
55
pkg/server/web/helpers_test.go
Normal file
55
pkg/server/web/helpers_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTextToHtml(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "html",
|
||||||
|
want: "html",
|
||||||
|
},
|
||||||
|
// Check it escapes.
|
||||||
|
{
|
||||||
|
input: "<html>",
|
||||||
|
want: "<html>",
|
||||||
|
},
|
||||||
|
// Check for linebreaks.
|
||||||
|
{
|
||||||
|
input: "line\nbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "line\r\nbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "line\rbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
// Check URL detection.
|
||||||
|
{
|
||||||
|
input: "http://google.com/",
|
||||||
|
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "http://a.com/?q=a&n=v",
|
||||||
|
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "(http://a.com/?q=a&n=v)",
|
||||||
|
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
got := TextToHTML(tc.input)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RenderJson(w http.ResponseWriter, data interface{}) error {
|
// RenderJSON sets the correct HTTP headers for JSON, then writes the specified
|
||||||
|
// data (typically a struct) encoded in JSON
|
||||||
|
func RenderJSON(w http.ResponseWriter, data interface{}) error {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.Header().Set("Expires", "-1")
|
w.Header().Set("Expires", "-1")
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
201
pkg/server/web/server.go
Normal file
201
pkg/server/web/server.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Package web provides the plumbing for Inbucket's web GUI and RESTful API
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"expvar"
|
||||||
|
"html/template"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// msgHub holds a reference to the message pub/sub system
|
||||||
|
msgHub *msghub.Hub
|
||||||
|
manager message.Manager
|
||||||
|
|
||||||
|
// Router is shared between httpd, webui and rest packages. It sends
|
||||||
|
// incoming requests to the correct handler function
|
||||||
|
Router = mux.NewRouter()
|
||||||
|
|
||||||
|
rootConfig *config.Root
|
||||||
|
server *http.Server
|
||||||
|
listener net.Listener
|
||||||
|
globalShutdown chan bool
|
||||||
|
|
||||||
|
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||||
|
ExpWebSocketConnectsCurrent = new(expvar.Int)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m := expvar.NewMap("http")
|
||||||
|
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sets up things for unit tests or the Start() method.
|
||||||
|
func Initialize(
|
||||||
|
conf *config.Root,
|
||||||
|
shutdownChan chan bool,
|
||||||
|
mm message.Manager,
|
||||||
|
mh *msghub.Hub) {
|
||||||
|
|
||||||
|
rootConfig = conf
|
||||||
|
globalShutdown = shutdownChan
|
||||||
|
|
||||||
|
// NewContext() will use this DataStore for the web handlers.
|
||||||
|
msgHub = mh
|
||||||
|
manager = mm
|
||||||
|
|
||||||
|
// Redirect requests to / if there is a base path configured.
|
||||||
|
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||||
|
redirectBase := prefix("/")
|
||||||
|
if redirectBase != "/" {
|
||||||
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||||
|
Msg("Base path configured")
|
||||||
|
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic paths.
|
||||||
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||||
|
Msg("Web UI content mapped")
|
||||||
|
Router.Handle(prefix("/debug/vars"), expvar.Handler())
|
||||||
|
if conf.Web.PProf {
|
||||||
|
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
|
||||||
|
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
|
||||||
|
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
|
||||||
|
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
|
||||||
|
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
|
||||||
|
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||||
|
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static paths.
|
||||||
|
Router.PathPrefix(prefix("/static")).Handler(
|
||||||
|
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||||
|
Router.Path(prefix("/favicon.png")).Handler(
|
||||||
|
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||||
|
|
||||||
|
// Parse index.html template, allowing for configuration to be passed to the SPA.
|
||||||
|
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
|
||||||
|
indexTmpl, err := template.ParseFiles(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
msg := "Failed to parse HTML template"
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
log.Error().
|
||||||
|
Str("module", "web").
|
||||||
|
Str("phase", "startup").
|
||||||
|
Str("path", indexPath).
|
||||||
|
Str("cwd", cwd).
|
||||||
|
Err(err).
|
||||||
|
Msg(msg)
|
||||||
|
// Create a dummy template to allow tests to pass.
|
||||||
|
indexTmpl, _ = template.New("index.html").Parse(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA managed paths.
|
||||||
|
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||||
|
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||||
|
Router.Path(prefix("/")).Handler(spaHandler)
|
||||||
|
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||||
|
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||||
|
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
|
||||||
|
|
||||||
|
// Error handlers.
|
||||||
|
Router.NotFoundHandler = noMatchHandler(
|
||||||
|
http.StatusNotFound, "No route matches URI path")
|
||||||
|
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||||
|
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening for HTTP requests
|
||||||
|
func Start(ctx context.Context) {
|
||||||
|
server = &http.Server{
|
||||||
|
Addr: rootConfig.Web.Addr,
|
||||||
|
Handler: requestLoggingWrapper(Router),
|
||||||
|
ReadTimeout: 60 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't use ListenAndServe because it lacks a way to close the listener
|
||||||
|
log.Info().Str("module", "web").Str("phase", "startup").Str("addr", server.Addr).
|
||||||
|
Msg("HTTP listening on tcp4")
|
||||||
|
var err error
|
||||||
|
listener, err = net.Listen("tcp", server.Addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
|
Msg("HTTP failed to start TCP4 listener")
|
||||||
|
emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener go routine
|
||||||
|
go serve(ctx)
|
||||||
|
|
||||||
|
// Wait for shutdown
|
||||||
|
select {
|
||||||
|
case _ = <-ctx.Done():
|
||||||
|
log.Debug().Str("module", "web").Str("phase", "shutdown").
|
||||||
|
Msg("HTTP server shutting down on request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing the listener will cause the serve() go routine to exit
|
||||||
|
if err := listener.Close(); err != nil {
|
||||||
|
log.Debug().Str("module", "web").Str("phase", "shutdown").Err(err).
|
||||||
|
Msg("Failed to close HTTP listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||||
|
o := &jsonAppConfig{
|
||||||
|
BasePath: webConfig.BasePath,
|
||||||
|
MonitorVisible: webConfig.MonitorVisible,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(o)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
|
Msg("Failed to convert app-config to JSON")
|
||||||
|
}
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: "app-config",
|
||||||
|
Value: url.PathEscape(string(b)),
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve begins serving HTTP requests
|
||||||
|
func serve(ctx context.Context) {
|
||||||
|
// server.Serve blocks until we close the listener
|
||||||
|
err := server.Serve(listener)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case _ = <-ctx.Done():
|
||||||
|
// Nop
|
||||||
|
default:
|
||||||
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
|
Msg("HTTP server failed")
|
||||||
|
emergencyShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emergencyShutdown() {
|
||||||
|
// Shutdown Inbucket
|
||||||
|
select {
|
||||||
|
case _ = <-globalShutdown:
|
||||||
|
default:
|
||||||
|
close(globalShutdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
pkg/storage/file/fmessage.go
Normal file
104
pkg/storage/file/fmessage.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message implements Message and contains a little bit of data about a
|
||||||
|
// particular email message, and methods to retrieve the rest of it from disk.
|
||||||
|
type Message struct {
|
||||||
|
mailbox *mbox
|
||||||
|
// Stored in GOB
|
||||||
|
Fid string
|
||||||
|
Fdate time.Time
|
||||||
|
Ffrom *mail.Address
|
||||||
|
Fto []*mail.Address
|
||||||
|
Fsubject string
|
||||||
|
Fsize int64
|
||||||
|
Fseen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMessage creates a new FileMessage object and sets the Date and ID fields.
|
||||||
|
// It will also delete messages over messageCap if configured.
|
||||||
|
func (mb *mbox) newMessage() (*Message, error) {
|
||||||
|
// Load index
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete old messages over messageCap
|
||||||
|
if mb.store.messageCap > 0 {
|
||||||
|
for len(mb.messages) >= mb.store.messageCap {
|
||||||
|
log.Info().Str("module", "storage").Str("mailbox", mb.name).
|
||||||
|
Msg("Mailbox over message cap")
|
||||||
|
id := mb.messages[0].ID()
|
||||||
|
if err := mb.removeMessage(id); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("mailbox", mb.name).Str("id", id).
|
||||||
|
Err(err).Msg("Unable to delete message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
date := time.Now()
|
||||||
|
id := generateID(date)
|
||||||
|
return &Message{mailbox: mb, Fid: id, Fdate: date}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailbox returns the name of the mailbox this message resides in.
|
||||||
|
func (m *Message) Mailbox() string {
|
||||||
|
return m.mailbox.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID gets the ID of the Message
|
||||||
|
func (m *Message) ID() string {
|
||||||
|
return m.Fid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date returns the date/time this Message was received by Inbucket
|
||||||
|
func (m *Message) Date() time.Time {
|
||||||
|
return m.Fdate
|
||||||
|
}
|
||||||
|
|
||||||
|
// From returns the value of the Message From header
|
||||||
|
func (m *Message) From() *mail.Address {
|
||||||
|
return m.Ffrom
|
||||||
|
}
|
||||||
|
|
||||||
|
// To returns the value of the Message To header
|
||||||
|
func (m *Message) To() []*mail.Address {
|
||||||
|
return m.Fto
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject returns the value of the Message Subject header
|
||||||
|
func (m *Message) Subject() string {
|
||||||
|
return m.Fsubject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the size of the Message on disk in bytes
|
||||||
|
func (m *Message) Size() int64 {
|
||||||
|
return m.Fsize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) rawPath() string {
|
||||||
|
return filepath.Join(m.mailbox.path, m.Fid+".raw")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source opens the .raw portion of a Message as an io.ReadCloser
|
||||||
|
func (m *Message) Source() (reader io.ReadCloser, err error) {
|
||||||
|
file, err := os.Open(m.rawPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seen returns the seen flag value.
|
||||||
|
func (m *Message) Seen() bool {
|
||||||
|
return m.Fseen
|
||||||
|
}
|
||||||
290
pkg/storage/file/fstore.go
Normal file
290
pkg/storage/file/fstore.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name of index file in each mailbox
|
||||||
|
const indexFileName = "index.gob"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// countChannel is filled with a sequential numbers (0000..9999), which are
|
||||||
|
// used by generateID() to generate unique message IDs. It's global
|
||||||
|
// because we only want one regardless of the number of DataStore objects
|
||||||
|
countChannel = make(chan int, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Start generator
|
||||||
|
go countGenerator(countChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populates the channel with numbers
|
||||||
|
func countGenerator(c chan int) {
|
||||||
|
for i := 0; true; i = (i + 1) % 10000 {
|
||||||
|
c <- i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store implements DataStore aand is the root of the mail storage
|
||||||
|
// hiearchy. It provides access to Mailbox objects
|
||||||
|
type Store struct {
|
||||||
|
hashLock storage.HashLock
|
||||||
|
path string
|
||||||
|
mailPath string
|
||||||
|
messageCap int
|
||||||
|
bufReaderPool sync.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new DataStore object using the specified path
|
||||||
|
func New(cfg config.Storage) (storage.Store, error) {
|
||||||
|
path := cfg.Params["path"]
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("'path' parameter not specified")
|
||||||
|
}
|
||||||
|
mailPath := filepath.Join(path, "mail")
|
||||||
|
if _, err := os.Stat(mailPath); err != nil {
|
||||||
|
// Mail datastore does not yet exist
|
||||||
|
if err = os.MkdirAll(mailPath, 0770); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mailPath).Err(err).
|
||||||
|
Msg("Error creating dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Store{
|
||||||
|
path: path,
|
||||||
|
mailPath: mailPath,
|
||||||
|
messageCap: cfg.MailboxMsgCap,
|
||||||
|
bufReaderPool: sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return bufio.NewReader(nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage adds a message to the specified mailbox.
|
||||||
|
func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
|
||||||
|
mb := fs.mbox(m.Mailbox())
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
r, err := m.Source()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Create a new message.
|
||||||
|
fm, err := mb.newMessage()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Ensure mailbox directory exists.
|
||||||
|
if err := mb.createDir(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Write the message content
|
||||||
|
file, err := os.Create(fm.rawPath())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
w := bufio.NewWriter(file)
|
||||||
|
size, err := io.Copy(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// Try to remove the file
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = r.Close()
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
// Try to remove the file
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
// Try to remove the file
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Update the index.
|
||||||
|
fm.Fdate = m.Date()
|
||||||
|
fm.Ffrom = m.From()
|
||||||
|
fm.Fto = m.To()
|
||||||
|
fm.Fsize = size
|
||||||
|
fm.Fsubject = m.Subject()
|
||||||
|
mb.messages = append(mb.messages, fm)
|
||||||
|
if err := mb.writeIndex(); err != nil {
|
||||||
|
// Try to remove the file
|
||||||
|
_ = os.Remove(fm.rawPath())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fm.Fid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage returns the messages in the named mailbox, or an error.
|
||||||
|
func (fs *Store) GetMessage(mailbox, id string) (storage.Message, error) {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.RLock()
|
||||||
|
defer mb.RUnlock()
|
||||||
|
return mb.getMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessages returns the messages in the named mailbox, or an error.
|
||||||
|
func (fs *Store) GetMessages(mailbox string) ([]storage.Message, error) {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.RLock()
|
||||||
|
defer mb.RUnlock()
|
||||||
|
return mb.getMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen flags the message as having been read.
|
||||||
|
func (fs *Store) MarkSeen(mailbox, id string) error {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range mb.messages {
|
||||||
|
if m.Fid == id {
|
||||||
|
if m.Fseen {
|
||||||
|
// Already marked seen.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.Fseen = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mb.writeIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMessage deletes a message by ID from the specified mailbox.
|
||||||
|
func (fs *Store) RemoveMessage(mailbox, id string) error {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
return mb.removeMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessages deletes all messages in the named mailbox, or returns an error.
|
||||||
|
func (fs *Store) PurgeMessages(mailbox string) error {
|
||||||
|
mb := fs.mbox(mailbox)
|
||||||
|
mb.Lock()
|
||||||
|
defer mb.Unlock()
|
||||||
|
return mb.purge()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
||||||
|
// continues to return true.
|
||||||
|
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||||
|
names1, err := readDirNames(fs.mailPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Loop over level 1 directories
|
||||||
|
for _, name1 := range names1 {
|
||||||
|
names2, err := readDirNames(fs.mailPath, name1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Loop over level 2 directories
|
||||||
|
for _, name2 := range names2 {
|
||||||
|
names3, err := readDirNames(fs.mailPath, name1, name2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Loop over mailboxes
|
||||||
|
for _, name3 := range names3 {
|
||||||
|
mb := fs.mboxFromHash(name3)
|
||||||
|
mb.RLock()
|
||||||
|
msgs, err := mb.getMessages()
|
||||||
|
mb.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !f(msgs) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mbox returns the named mailbox.
|
||||||
|
func (fs *Store) mbox(mailbox string) *mbox {
|
||||||
|
hash := stringutil.HashMailboxName(mailbox)
|
||||||
|
s1 := hash[0:3]
|
||||||
|
s2 := hash[0:6]
|
||||||
|
path := filepath.Join(fs.mailPath, s1, s2, hash)
|
||||||
|
indexPath := filepath.Join(path, indexFileName)
|
||||||
|
return &mbox{
|
||||||
|
RWMutex: fs.hashLock.Get(hash),
|
||||||
|
store: fs,
|
||||||
|
name: mailbox,
|
||||||
|
dirName: hash,
|
||||||
|
path: path,
|
||||||
|
indexPath: indexPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mboxFromPath constructs a mailbox based on name hash.
|
||||||
|
func (fs *Store) mboxFromHash(hash string) *mbox {
|
||||||
|
s1 := hash[0:3]
|
||||||
|
s2 := hash[0:6]
|
||||||
|
path := filepath.Join(fs.mailPath, s1, s2, hash)
|
||||||
|
indexPath := filepath.Join(path, indexFileName)
|
||||||
|
return &mbox{
|
||||||
|
RWMutex: fs.hashLock.Get(hash),
|
||||||
|
store: fs,
|
||||||
|
dirName: hash,
|
||||||
|
path: path,
|
||||||
|
indexPath: indexPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPooledReader pulls a buffered reader from the fs.bufReaderPool.
|
||||||
|
func (fs *Store) getPooledReader(r io.Reader) *bufio.Reader {
|
||||||
|
br := fs.bufReaderPool.Get().(*bufio.Reader)
|
||||||
|
br.Reset(r)
|
||||||
|
return br
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPooledReader returns a buffered reader to the fs.bufReaderPool.
|
||||||
|
func (fs *Store) putPooledReader(br *bufio.Reader) {
|
||||||
|
fs.bufReaderPool.Put(br)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePrefix converts a Time object into the ISO style format we use
|
||||||
|
// as a prefix for message files. Note: It is used directly by unit
|
||||||
|
// tests.
|
||||||
|
func generatePrefix(date time.Time) string {
|
||||||
|
return date.Format("20060102T150405")
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateId adds a 4-digit unique number onto the end of the string
|
||||||
|
// returned by generatePrefix().
|
||||||
|
func generateID(date time.Time) string {
|
||||||
|
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDirNames returns a slice of filenames in the specified directory or an error.
|
||||||
|
func readDirNames(elem ...string) ([]string, error) {
|
||||||
|
f, err := os.Open(filepath.Join(elem...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.Readdirnames(0)
|
||||||
|
}
|
||||||
257
pkg/storage/file/fstore_test.go
Normal file
257
pkg/storage/file/fstore_test.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSuite runs storage package test suite on file store.
|
||||||
|
func TestSuite(t *testing.T) {
|
||||||
|
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
|
||||||
|
ds, _ := setupDataStore(conf)
|
||||||
|
destroy := func() {
|
||||||
|
teardownDataStore(ds)
|
||||||
|
}
|
||||||
|
return ds, destroy, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test directory structure created by filestore
|
||||||
|
func TestFSDirStructure(t *testing.T) {
|
||||||
|
ds, logbuf := setupDataStore(config.Storage{})
|
||||||
|
defer teardownDataStore(ds)
|
||||||
|
root := ds.path
|
||||||
|
|
||||||
|
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||||
|
mbName := "james"
|
||||||
|
|
||||||
|
// Check filestore root exists
|
||||||
|
assert.True(t, isDir(root), "Expected %q to be a directory", root)
|
||||||
|
|
||||||
|
// Check mail dir exists
|
||||||
|
expect := filepath.Join(root, "mail")
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
|
||||||
|
// Check first hash section does not exist
|
||||||
|
expect = filepath.Join(root, "mail", "474")
|
||||||
|
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
|
||||||
|
|
||||||
|
// Deliver test message
|
||||||
|
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
|
||||||
|
|
||||||
|
// Check path to message exists
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
expect = filepath.Join(expect, "474ba6")
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
|
||||||
|
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
||||||
|
|
||||||
|
// Check files
|
||||||
|
mbPath := expect
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
expect = filepath.Join(mbPath, id1+".raw")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
|
||||||
|
// Deliver second test message
|
||||||
|
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||||
|
|
||||||
|
// Check files
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
expect = filepath.Join(mbPath, id2+".raw")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
|
||||||
|
// Delete message
|
||||||
|
err := ds.RemoveMessage(mbName, id1)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// Message should be removed
|
||||||
|
expect = filepath.Join(mbPath, id1+".raw")
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
||||||
|
|
||||||
|
// Delete message
|
||||||
|
err = ds.RemoveMessage(mbName, id2)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// Message should be removed
|
||||||
|
expect = filepath.Join(mbPath, id2+".raw")
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
|
||||||
|
// No messages, index & maildir should be removed
|
||||||
|
expect = filepath.Join(mbPath, "index.gob")
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
expect = mbPath
|
||||||
|
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test missing files
|
||||||
|
func TestFSMissing(t *testing.T) {
|
||||||
|
ds, logbuf := setupDataStore(config.Storage{})
|
||||||
|
defer teardownDataStore(ds)
|
||||||
|
|
||||||
|
mbName := "fred"
|
||||||
|
subjects := []string{"a", "b", "c"}
|
||||||
|
sentIds := make([]string, len(subjects))
|
||||||
|
|
||||||
|
for i, subj := range subjects {
|
||||||
|
// Add a message
|
||||||
|
id, _ := deliverMessage(ds, mbName, subj, time.Now())
|
||||||
|
sentIds[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a message file without removing it from index
|
||||||
|
msg, err := ds.GetMessage(mbName, sentIds[1])
|
||||||
|
assert.Nil(t, err)
|
||||||
|
fmsg := msg.(*Message)
|
||||||
|
_ = os.Remove(fmsg.rawPath())
|
||||||
|
msg, err = ds.GetMessage(mbName, sentIds[1])
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
// Try to read parts of message
|
||||||
|
_, err = msg.Source()
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Get the latest message
|
||||||
|
func TestGetLatestMessage(t *testing.T) {
|
||||||
|
ds, logbuf := setupDataStore(config.Storage{})
|
||||||
|
defer teardownDataStore(ds)
|
||||||
|
|
||||||
|
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||||
|
mbName := "james"
|
||||||
|
|
||||||
|
// Test empty mailbox
|
||||||
|
msg, err := ds.GetMessage(mbName, "latest")
|
||||||
|
assert.Nil(t, msg)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Deliver test message
|
||||||
|
deliverMessage(ds, mbName, "test", time.Now())
|
||||||
|
|
||||||
|
// Deliver test message 2
|
||||||
|
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||||
|
|
||||||
|
// Test get the latest message
|
||||||
|
msg, err = ds.GetMessage(mbName, "latest")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
|
||||||
|
|
||||||
|
// Deliver test message 3
|
||||||
|
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
|
||||||
|
|
||||||
|
msg, err = ds.GetMessage(mbName, "latest")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
|
||||||
|
|
||||||
|
// Test wrong id
|
||||||
|
_, err = ds.GetMessage(mbName, "wrongid")
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
if t.Failed() {
|
||||||
|
// Wait for handler to finish logging
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupDataStore creates a new FileDataStore in a temporary directory
|
||||||
|
func setupDataStore(cfg config.Storage) (*Store, *bytes.Buffer) {
|
||||||
|
path, err := ioutil.TempDir("", "inbucket")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// Capture log output.
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
log.SetOutput(buf)
|
||||||
|
if cfg.Params == nil {
|
||||||
|
cfg.Params = make(map[string]string)
|
||||||
|
}
|
||||||
|
cfg.Params["path"] = path
|
||||||
|
s, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return s.(*Store), buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverMessage creates and delivers a message to the specific mailbox, returning
|
||||||
|
// the size of the generated message.
|
||||||
|
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
|
||||||
|
// Build message for delivery
|
||||||
|
meta := message.Metadata{
|
||||||
|
Mailbox: mbName,
|
||||||
|
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
|
||||||
|
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
|
||||||
|
Subject: subject,
|
||||||
|
Date: date,
|
||||||
|
}
|
||||||
|
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
|
||||||
|
meta.To[0].Address, meta.From.Address, subject)
|
||||||
|
delivery := &message.Delivery{
|
||||||
|
Meta: meta,
|
||||||
|
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
|
||||||
|
}
|
||||||
|
id, err := ds.AddMessage(delivery)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return id, int64(len(testMsg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownDataStore(ds *Store) {
|
||||||
|
if err := os.RemoveAll(ds.path); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPresent(path string) bool {
|
||||||
|
_, err := os.Lstat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFile(path string) bool {
|
||||||
|
if fi, err := os.Lstat(path); err == nil {
|
||||||
|
return !fi.IsDir()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDir(path string) bool {
|
||||||
|
if fi, err := os.Lstat(path); err == nil {
|
||||||
|
return fi.IsDir()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
239
pkg/storage/file/mbox.go
Normal file
239
pkg/storage/file/mbox.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mbox manages the mail for a specific user and correlates to a particular directory on disk.
|
||||||
|
// mbox methods are not thread safe, mbox.RWMutex must be held prior to calling.
|
||||||
|
type mbox struct {
|
||||||
|
*sync.RWMutex
|
||||||
|
store *Store
|
||||||
|
name string
|
||||||
|
dirName string
|
||||||
|
path string
|
||||||
|
indexLoaded bool
|
||||||
|
indexPath string
|
||||||
|
messages []*Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessages scans the mailbox directory for .gob files and decodes them into
|
||||||
|
// a slice of Message objects.
|
||||||
|
func (mb *mbox) getMessages() ([]storage.Message, error) {
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages := make([]storage.Message, len(mb.messages))
|
||||||
|
for i, m := range mb.messages {
|
||||||
|
messages[i] = m
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessage decodes a single message by ID and returns a Message object.
|
||||||
|
func (mb *mbox) getMessage(id string) (storage.Message, error) {
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "latest" && len(mb.messages) != 0 {
|
||||||
|
return mb.messages[len(mb.messages)-1], nil
|
||||||
|
}
|
||||||
|
for _, m := range mb.messages {
|
||||||
|
if m.Fid == id {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, storage.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMessage deletes the message off disk and removes it from the index.
|
||||||
|
func (mb *mbox) removeMessage(id string) error {
|
||||||
|
if !mb.indexLoaded {
|
||||||
|
if err := mb.readIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var msg *Message
|
||||||
|
for i, m := range mb.messages {
|
||||||
|
if id == m.ID() {
|
||||||
|
msg = m
|
||||||
|
// Slice around message we are deleting
|
||||||
|
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
return storage.ErrNotExist
|
||||||
|
}
|
||||||
|
if err := mb.writeIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(mb.messages) == 0 {
|
||||||
|
// This was the last message, thus writeIndex() has removed the entire
|
||||||
|
// directory; we don't need to delete the raw file.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// There are still messages in the index
|
||||||
|
log.Debug().Str("module", "storage").Str("path", msg.rawPath()).Msg("Deleting file")
|
||||||
|
return os.Remove(msg.rawPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// purge deletes all messages in this mailbox.
|
||||||
|
func (mb *mbox) purge() error {
|
||||||
|
mb.messages = mb.messages[:0]
|
||||||
|
return mb.writeIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readIndex loads the mailbox index data from disk
|
||||||
|
func (mb *mbox) readIndex() error {
|
||||||
|
// Clear message slice, open index
|
||||||
|
mb.messages = mb.messages[:0]
|
||||||
|
// Check if index exists
|
||||||
|
if _, err := os.Stat(mb.indexPath); err != nil {
|
||||||
|
// Does not exist, but that's not an error in our world
|
||||||
|
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
|
||||||
|
Msg("Index does not yet exist")
|
||||||
|
mb.indexLoaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
file, err := os.Open(mb.indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mb.indexPath).Err(err).
|
||||||
|
Msg("Failed to close")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Decode gob data
|
||||||
|
br := mb.store.getPooledReader(file)
|
||||||
|
defer mb.store.putPooledReader(br)
|
||||||
|
dec := gob.NewDecoder(br)
|
||||||
|
name := ""
|
||||||
|
if err = dec.Decode(&name); err != nil {
|
||||||
|
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||||
|
}
|
||||||
|
mb.name = name
|
||||||
|
for {
|
||||||
|
// Load messages until EOF
|
||||||
|
msg := &Message{}
|
||||||
|
if err = dec.Decode(msg); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||||
|
}
|
||||||
|
msg.mailbox = mb
|
||||||
|
mb.messages = append(mb.messages, msg)
|
||||||
|
}
|
||||||
|
mb.indexLoaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeIndex overwrites the index on disk with the current mailbox data
|
||||||
|
func (mb *mbox) writeIndex() error {
|
||||||
|
// Lock for writing
|
||||||
|
if len(mb.messages) > 0 {
|
||||||
|
// Ensure mailbox directory exists
|
||||||
|
if err := mb.createDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Open index for writing
|
||||||
|
file, err := os.Create(mb.indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
// Write each message and then flush
|
||||||
|
enc := gob.NewEncoder(writer)
|
||||||
|
if err = enc.Encode(mb.name); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, m := range mb.messages {
|
||||||
|
if err = enc.Encode(m); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Flush(); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mb.indexPath).Err(err).
|
||||||
|
Msg("Failed to close")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No messages, delete index+maildir
|
||||||
|
log.Debug().Str("module", "storage").Str("path", mb.path).Msg("Removing mailbox")
|
||||||
|
return mb.removeDir()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDir checks for the presence of the path for this mailbox, creates it if needed
|
||||||
|
func (mb *mbox) createDir() error {
|
||||||
|
if _, err := os.Stat(mb.path); err != nil {
|
||||||
|
if err := os.MkdirAll(mb.path, 0770); err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", mb.path).Err(err).
|
||||||
|
Msg("Failed to create directory")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDir removes the mailbox, plus empty higher level directories
|
||||||
|
func (mb *mbox) removeDir() error {
|
||||||
|
// remove mailbox dir, including index file
|
||||||
|
if err := os.RemoveAll(mb.path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// remove parents if empty
|
||||||
|
dir := filepath.Dir(mb.path)
|
||||||
|
if removeDirIfEmpty(dir) {
|
||||||
|
removeDirIfEmpty(filepath.Dir(dir))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
|
||||||
|
// Returns true if dir was removed.
|
||||||
|
func removeDirIfEmpty(path string) (removed bool) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
files, err := f.Readdirnames(0)
|
||||||
|
_ = f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(files) > 0 {
|
||||||
|
// Dir not empty
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Debug().Str("module", "storage").Str("path", path).Msg("Removing dir")
|
||||||
|
err = os.Remove(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "storage").Str("path", path).Err(err).Msg("Failed to remove")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
23
pkg/storage/lock.go
Normal file
23
pkg/storage/lock.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashLock holds a fixed length array of mutexes. This approach allows concurrent mailbox
|
||||||
|
// access in most cases without requiring an infinite number of mutexes.
|
||||||
|
type HashLock [4096]sync.RWMutex
|
||||||
|
|
||||||
|
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexidecimal
|
||||||
|
// string of three or more characters.
|
||||||
|
func (h *HashLock) Get(hash string) *sync.RWMutex {
|
||||||
|
if len(hash) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i, err := strconv.ParseInt(hash[0:3], 16, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &h[i]
|
||||||
|
}
|
||||||
61
pkg/storage/lock_test.go
Normal file
61
pkg/storage/lock_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package storage_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashLock(t *testing.T) {
|
||||||
|
hl := &storage.HashLock{}
|
||||||
|
|
||||||
|
// Invalid hashes
|
||||||
|
testCases := []struct {
|
||||||
|
name, input string
|
||||||
|
}{
|
||||||
|
{"empty", ""},
|
||||||
|
{"short", "a0"},
|
||||||
|
{"badhex", "zzzzzzzzzzzzzzzzzzzzzzz"},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
l := hl.Get(tc.input)
|
||||||
|
if l != nil {
|
||||||
|
t.Errorf("Expected nil lock for %s %q, got %v", tc.name, tc.input, l)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid hashes
|
||||||
|
testStrings := []string{
|
||||||
|
"deadbeef",
|
||||||
|
"00000000",
|
||||||
|
"ffffffff",
|
||||||
|
}
|
||||||
|
for _, ts := range testStrings {
|
||||||
|
t.Run(ts, func(t *testing.T) {
|
||||||
|
l := hl.Get(ts)
|
||||||
|
if l == nil {
|
||||||
|
t.Errorf("Expeced non-nil lock for hex string %q", ts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
a := hl.Get("deadbeef")
|
||||||
|
b := hl.Get("deadbeef")
|
||||||
|
if a != b {
|
||||||
|
t.Errorf("Expected identical locks for identical hashes, got: %p != %p", a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
a = hl.Get("deadbeef")
|
||||||
|
b = hl.Get("d3adb33f")
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("Expected different locks for different hashes, got: %p == %p", a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
a = hl.Get("deadbeef")
|
||||||
|
b = hl.Get("deadb33f")
|
||||||
|
if a != b {
|
||||||
|
t.Errorf("Expected identical locks for identical leading hashes, got: %p != %p", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
pkg/storage/mem/maxsize.go
Normal file
73
pkg/storage/mem/maxsize.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import "container/list"
|
||||||
|
|
||||||
|
type msgDone struct {
|
||||||
|
msg *Message
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSizeEnforcer will delete the oldest message until the entire mail store is equal to or less
|
||||||
|
// than maxSize bytes.
|
||||||
|
func (s *Store) maxSizeEnforcer(maxSize int64) {
|
||||||
|
all := &list.List{}
|
||||||
|
curSize := int64(0)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case md, ok := <-s.incoming:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Add message to all.
|
||||||
|
m := md.msg
|
||||||
|
el := all.PushBack(m)
|
||||||
|
m.el = el
|
||||||
|
curSize += int64(m.Size())
|
||||||
|
for curSize > maxSize {
|
||||||
|
// Remove oldest message.
|
||||||
|
el := all.Front()
|
||||||
|
all.Remove(el)
|
||||||
|
m := el.Value.(*Message)
|
||||||
|
if s.removeMessage(m.mailbox, m.id) != nil {
|
||||||
|
curSize -= int64(m.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(md.done)
|
||||||
|
case md, ok := <-s.remove:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Remove message from all.
|
||||||
|
m := md.msg
|
||||||
|
el := all.Remove(m.el)
|
||||||
|
if el != nil {
|
||||||
|
curSize -= int64(m.Size())
|
||||||
|
}
|
||||||
|
close(md.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforcerDeliver sends delivery to enforcer if configured, and waits for completion.
|
||||||
|
func (s *Store) enforcerDeliver(m *Message) {
|
||||||
|
if s.incoming != nil {
|
||||||
|
md := &msgDone{
|
||||||
|
msg: m,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
s.incoming <- md
|
||||||
|
<-md.done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforcerRemove sends removal to enforcer if configured, and waits for completion.
|
||||||
|
func (s *Store) enforcerRemove(m *Message) {
|
||||||
|
if s.remove != nil {
|
||||||
|
md := &msgDone{
|
||||||
|
msg: m,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
s.remove <- md
|
||||||
|
<-md.done
|
||||||
|
}
|
||||||
|
}
|
||||||
57
pkg/storage/mem/message.go
Normal file
57
pkg/storage/mem/message.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"container/list"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is a memory store message.
|
||||||
|
type Message struct {
|
||||||
|
index int
|
||||||
|
mailbox string
|
||||||
|
id string
|
||||||
|
from *mail.Address
|
||||||
|
to []*mail.Address
|
||||||
|
date time.Time
|
||||||
|
subject string
|
||||||
|
source []byte
|
||||||
|
seen bool
|
||||||
|
el *list.Element // This message in Store.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Message = &Message{}
|
||||||
|
|
||||||
|
// Mailbox returns the mailbox name.
|
||||||
|
func (m *Message) Mailbox() string { return m.mailbox }
|
||||||
|
|
||||||
|
// ID the message ID.
|
||||||
|
func (m *Message) ID() string { return m.id }
|
||||||
|
|
||||||
|
// From returns the from address.
|
||||||
|
func (m *Message) From() *mail.Address { return m.from }
|
||||||
|
|
||||||
|
// To returns the to address list.
|
||||||
|
func (m *Message) To() []*mail.Address { return m.to }
|
||||||
|
|
||||||
|
// Date returns the date received.
|
||||||
|
func (m *Message) Date() time.Time { return m.date }
|
||||||
|
|
||||||
|
// Subject returns the subject line.
|
||||||
|
func (m *Message) Subject() string { return m.subject }
|
||||||
|
|
||||||
|
// Source returns a reader for the message source.
|
||||||
|
func (m *Message) Source() (io.ReadCloser, error) {
|
||||||
|
return ioutil.NopCloser(bytes.NewReader(m.source)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the message size in bytes.
|
||||||
|
func (m *Message) Size() int64 { return int64(len(m.source)) }
|
||||||
|
|
||||||
|
// Seen returns the message seen flag.
|
||||||
|
func (m *Message) Seen() bool { return m.seen }
|
||||||
223
pkg/storage/mem/store.go
Normal file
223
pkg/storage/mem/store.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package mem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store implements an in-memory message store.
|
||||||
|
type Store struct {
|
||||||
|
sync.Mutex
|
||||||
|
boxes map[string]*mbox
|
||||||
|
cap int // Per-mailbox message cap.
|
||||||
|
incoming chan *msgDone // New messages for size enforcer.
|
||||||
|
remove chan *msgDone // Remove deleted messages from size enforcer.
|
||||||
|
}
|
||||||
|
|
||||||
|
type mbox struct {
|
||||||
|
sync.RWMutex
|
||||||
|
name string
|
||||||
|
last int
|
||||||
|
first int
|
||||||
|
messages map[string]*Message
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Store = &Store{}
|
||||||
|
|
||||||
|
// New returns an emtpy memory store.
|
||||||
|
func New(cfg config.Storage) (storage.Store, error) {
|
||||||
|
s := &Store{
|
||||||
|
boxes: make(map[string]*mbox),
|
||||||
|
cap: cfg.MailboxMsgCap,
|
||||||
|
}
|
||||||
|
if str, ok := cfg.Params["maxkb"]; ok {
|
||||||
|
maxKB, err := strconv.ParseInt(str, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse maxkb: %v", err)
|
||||||
|
}
|
||||||
|
if maxKB > 0 {
|
||||||
|
// Setup enforcer.
|
||||||
|
s.incoming = make(chan *msgDone)
|
||||||
|
s.remove = make(chan *msgDone)
|
||||||
|
go s.maxSizeEnforcer(maxKB * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage stores the message, message ID and Size will be ignored.
|
||||||
|
func (s *Store) AddMessage(message storage.Message) (id string, err error) {
|
||||||
|
r, ierr := message.Source()
|
||||||
|
if ierr != nil {
|
||||||
|
err = ierr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, ierr := ioutil.ReadAll(r)
|
||||||
|
if ierr != nil {
|
||||||
|
err = ierr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := &Message{
|
||||||
|
mailbox: message.Mailbox(),
|
||||||
|
from: message.From(),
|
||||||
|
to: message.To(),
|
||||||
|
date: message.Date(),
|
||||||
|
subject: message.Subject(),
|
||||||
|
}
|
||||||
|
s.withMailbox(message.Mailbox(), true, func(mb *mbox) {
|
||||||
|
// Generate message ID.
|
||||||
|
mb.last++
|
||||||
|
m.index = mb.last
|
||||||
|
id = strconv.Itoa(mb.last)
|
||||||
|
m.id = id
|
||||||
|
m.source = source
|
||||||
|
mb.messages[id] = m
|
||||||
|
if s.cap > 0 {
|
||||||
|
// Enforce cap.
|
||||||
|
for len(mb.messages) > s.cap {
|
||||||
|
delete(mb.messages, strconv.Itoa(mb.first))
|
||||||
|
mb.first++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s.enforcerDeliver(m)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage gets a mesage.
|
||||||
|
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
|
||||||
|
if id == "latest" {
|
||||||
|
ms, err := s.GetMessages(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := len(ms)
|
||||||
|
if count == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return ms[count-1], nil
|
||||||
|
}
|
||||||
|
s.withMailbox(mailbox, false, func(mb *mbox) {
|
||||||
|
var ok bool
|
||||||
|
m, ok = mb.messages[id]
|
||||||
|
if !ok {
|
||||||
|
m = nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessages gets a list of messages.
|
||||||
|
func (s *Store) GetMessages(mailbox string) (ms []storage.Message, err error) {
|
||||||
|
s.withMailbox(mailbox, false, func(mb *mbox) {
|
||||||
|
ms = make([]storage.Message, 0, len(mb.messages))
|
||||||
|
for _, v := range mb.messages {
|
||||||
|
ms = append(ms, v)
|
||||||
|
}
|
||||||
|
sort.Slice(ms, func(i, j int) bool {
|
||||||
|
return ms[i].(*Message).index < ms[j].(*Message).index
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return ms, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen marks a message as having been read.
|
||||||
|
func (s *Store) MarkSeen(mailbox, id string) error {
|
||||||
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
|
m := mb.messages[id]
|
||||||
|
if m != nil {
|
||||||
|
m.seen = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessages deletes the contents of a mailbox.
|
||||||
|
func (s *Store) PurgeMessages(mailbox string) error {
|
||||||
|
var messages map[string]*Message
|
||||||
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
|
messages = mb.messages
|
||||||
|
mb.messages = make(map[string]*Message)
|
||||||
|
})
|
||||||
|
if len(messages) > 0 && s.remove != nil {
|
||||||
|
for _, m := range messages {
|
||||||
|
s.enforcerRemove(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeMessage deletes a single message without notifying the size enforcer. Returns the message
|
||||||
|
// that was removed.
|
||||||
|
func (s *Store) removeMessage(mailbox, id string) *Message {
|
||||||
|
var m *Message
|
||||||
|
s.withMailbox(mailbox, true, func(mb *mbox) {
|
||||||
|
m = mb.messages[id]
|
||||||
|
if m != nil {
|
||||||
|
delete(mb.messages, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMessage deletes a single message.
|
||||||
|
func (s *Store) RemoveMessage(mailbox, id string) error {
|
||||||
|
m := s.removeMessage(mailbox, id)
|
||||||
|
if m != nil {
|
||||||
|
s.enforcerRemove(m)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisitMailboxes visits each mailbox in the store.
|
||||||
|
func (s *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||||
|
// Lock store, get names of all mailboxes.
|
||||||
|
s.Lock()
|
||||||
|
boxNames := make([]string, 0, len(s.boxes))
|
||||||
|
for k := range s.boxes {
|
||||||
|
boxNames = append(boxNames, k)
|
||||||
|
}
|
||||||
|
s.Unlock()
|
||||||
|
// Process mailboxes.
|
||||||
|
for _, mailbox := range boxNames {
|
||||||
|
ms, _ := s.GetMessages(mailbox)
|
||||||
|
if !f(ms) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withMailbox gets or creates a mailbox, locks it, then calls f.
|
||||||
|
func (s *Store) withMailbox(mailbox string, writeLock bool, f func(mb *mbox)) {
|
||||||
|
s.Lock()
|
||||||
|
mb, ok := s.boxes[mailbox]
|
||||||
|
if !ok {
|
||||||
|
// Create mailbox
|
||||||
|
mb = &mbox{
|
||||||
|
name: mailbox,
|
||||||
|
messages: make(map[string]*Message),
|
||||||
|
}
|
||||||
|
s.boxes[mailbox] = mb
|
||||||
|
}
|
||||||
|
s.Unlock()
|
||||||
|
if writeLock {
|
||||||
|
mb.Lock()
|
||||||
|
} else {
|
||||||
|
mb.RLock()
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if writeLock {
|
||||||
|
mb.Unlock()
|
||||||
|
} else {
|
||||||
|
mb.RUnlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f(mb)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user