mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 01:57:02 +00:00
Compare commits
540 Commits
1.2.0-rc1
...
v3.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87888e9dbf | ||
|
|
e84d21cb28 | ||
|
|
5a886813c3 | ||
|
|
95281566f6 | ||
|
|
7044567d64 | ||
|
|
82ddf2141c | ||
|
|
b554c7db83 | ||
|
|
36095a2cdf | ||
|
|
e1b8996412 | ||
|
|
71d3e8df3b | ||
|
|
2da7ad61cd | ||
|
|
eaae1a1e44 | ||
|
|
a55da8b7d1 | ||
|
|
561ed93451 | ||
|
|
ef12d02b83 | ||
|
|
de617b6a73 | ||
|
|
69b6225554 | ||
|
|
5adef42df7 | ||
|
|
d11ae3710c | ||
|
|
5d18d79539 | ||
|
|
b38b2e9760 | ||
|
|
75b7c69b5c | ||
|
|
239426692e | ||
|
|
17b054b5a1 | ||
|
|
7f91c3e9cb | ||
|
|
55addbb556 | ||
|
|
8fd5cdfc86 | ||
|
|
e74efbaa77 | ||
|
|
b383fbf9ab | ||
|
|
c9912bc2bb | ||
|
|
f0d457b8f5 | ||
|
|
3bf4b5c39b | ||
|
|
37806f222d | ||
|
|
f5899c293c | ||
|
|
cd9c3d61ee | ||
|
|
37d314fd2e | ||
|
|
28b0557865 | ||
|
|
997cb55847 | ||
|
|
61454a0c9c | ||
|
|
e875a4c382 | ||
|
|
bc6548b6f3 | ||
|
|
911a6c8d78 | ||
|
|
547a12ffca | ||
|
|
c8d22ac802 | ||
|
|
9dbffa88de | ||
|
|
eae4926b23 | ||
|
|
29d1ed1e7f | ||
|
|
1f1a8b4192 | ||
|
|
344c3ffb21 | ||
|
|
87018ed42d | ||
|
|
1650a5b375 | ||
|
|
3f7adbfb22 | ||
|
|
03cc31fb70 | ||
|
|
a10a6244c9 | ||
|
|
9185423022 | ||
|
|
9aaca449f8 | ||
|
|
f39395bd7f | ||
|
|
2c68128d5d | ||
|
|
06d4120682 | ||
|
|
58bcd4f557 | ||
|
|
e91e8d5aee | ||
|
|
5322462899 | ||
|
|
5def9ed183 | ||
|
|
357589d90e | ||
|
|
b664bcfc4c | ||
|
|
ffd13e2ee7 | ||
|
|
747775b8f2 | ||
|
|
2c0d942c76 | ||
|
|
e7263439d5 | ||
|
|
cb6f99c487 | ||
|
|
04fb58e15e | ||
|
|
f11ad55474 | ||
|
|
26939f2bf6 | ||
|
|
05a3b1742a | ||
|
|
867d5f5d7f | ||
|
|
8e34a21dc6 | ||
|
|
8869acef0b | ||
|
|
752d5c9668 | ||
|
|
091e26c467 | ||
|
|
6593a36b48 | ||
|
|
68ef2d9873 | ||
|
|
ab988caf6b | ||
|
|
fa62220d98 | ||
|
|
1ecf424975 | ||
|
|
3342938dd4 | ||
|
|
6be1655723 | ||
|
|
1465e6fb49 | ||
|
|
21991cbfc7 | ||
|
|
7138a97935 | ||
|
|
beee68fc5d | ||
|
|
9e2af71743 | ||
|
|
a2c4292fc1 | ||
|
|
2016142747 | ||
|
|
4f9f961cac | ||
|
|
bf8536abb3 | ||
|
|
985f2702f2 | ||
|
|
11f3879442 | ||
|
|
8562c55c98 | ||
|
|
e3066bb535 | ||
|
|
35ab31efbc | ||
|
|
81edf40996 | ||
|
|
c64e7a6a6c | ||
|
|
4bd64563f2 | ||
|
|
66dec49a49 | ||
|
|
649e3743e0 | ||
|
|
c096f018d6 | ||
|
|
261bbef426 | ||
|
|
3c5960aba0 | ||
|
|
7f430f2bde | ||
|
|
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 | ||
|
|
5ef3adc88e | ||
|
|
1742bf9a34 | ||
|
|
c2779ff054 |
@@ -6,3 +6,8 @@ inbucket
|
||||
inbucket.exe
|
||||
swaks-tests
|
||||
target
|
||||
tags
|
||||
tags.*
|
||||
ui/dist
|
||||
ui/elm-stuff
|
||||
ui/node_modules
|
||||
|
||||
36
.github/workflows/build-and-test.yml
vendored
Normal file
36
.github/workflows/build-and-test.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build and Test
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
go-build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: [ '1.20', '1.19' ]
|
||||
name: Go ${{ matrix.go }} build
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Build and test
|
||||
run: |
|
||||
go build ./...
|
||||
go test -race -coverprofile=profile.cov ./...
|
||||
- name: Send coverage
|
||||
uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
path-to-profile: profile.cov
|
||||
flag-name: Go-${{ matrix.go }}
|
||||
parallel: true
|
||||
coverage:
|
||||
needs: go-build
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: shogo82148/actions-goveralls@v1
|
||||
with:
|
||||
parallel-finished: true
|
||||
54
.github/workflows/docker-build.yml
vendored
Normal file
54
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
inbucket/inbucket
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha
|
||||
type=edge,branch=main
|
||||
flavor: |
|
||||
latest=auto
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
with:
|
||||
platforms: all
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64, linux/arm/v7
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
42
.github/workflows/release.yml
vendored
Normal file
42
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Build and Release
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: 1.19
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16.x'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: ui/yarn.lock
|
||||
- name: Build frontend
|
||||
run: |
|
||||
yarn install --frozen-lockfile --non-interactive
|
||||
yarn run build
|
||||
working-directory: ./ui
|
||||
- name: Test build release
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
with:
|
||||
version: latest
|
||||
args: release --snapshot
|
||||
- name: Build and publish release
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
if: "startsWith(github.ref, 'refs/tags/v')"
|
||||
with:
|
||||
version: latest
|
||||
args: release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
36
.gitignore
vendored
36
.gitignore
vendored
@@ -21,14 +21,40 @@ _testmain.go
|
||||
|
||||
*.exe
|
||||
|
||||
# vim swp files
|
||||
# vim files
|
||||
*.swp
|
||||
*.swo
|
||||
tags
|
||||
tags.*
|
||||
|
||||
# our binary
|
||||
# Desktop Services Store on macOS
|
||||
.DS_Store
|
||||
|
||||
/.direnv
|
||||
|
||||
# Inbucket binaries
|
||||
/client
|
||||
/client.exe
|
||||
/inbucket
|
||||
/inbucket.exe
|
||||
/target/**
|
||||
/dist/**
|
||||
/cmd/client/client
|
||||
/cmd/client/client.exe
|
||||
/cmd/inbucket/inbucket
|
||||
/cmd/inbucket/inbucket.exe
|
||||
|
||||
# local goxc config
|
||||
.goxc.local.json
|
||||
# Elm UI
|
||||
# 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
|
||||
/ui/.parcel-cache
|
||||
|
||||
# Test lua files
|
||||
/inbucket.lua
|
||||
|
||||
90
.goreleaser.yml
Normal file
90
.goreleaser.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
project_name: inbucket
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: inbucket
|
||||
name: inbucket
|
||||
name_template: '{{.Tag}}'
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
|
||||
builds:
|
||||
- id: inbucket
|
||||
binary: inbucket
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- "7"
|
||||
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
|
||||
- arm
|
||||
- arm64
|
||||
goarm:
|
||||
- "7"
|
||||
main: ./cmd/client
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
|
||||
archives:
|
||||
- id: tarball
|
||||
rlcp: true
|
||||
format: tar.gz
|
||||
wrap_in_directory: true
|
||||
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
|
||||
contents:
|
||||
- src: "ui/dist/**"
|
||||
dst: "/usr/local/share/inbucket/ui"
|
||||
- src: "etc/linux/inbucket.service"
|
||||
dst: "/lib/systemd/system/inbucket.service"
|
||||
type: config|noreplace
|
||||
- src: "ui/greeting.html"
|
||||
dst: "/etc/inbucket/greeting.html"
|
||||
type: config|noreplace
|
||||
|
||||
snapshot:
|
||||
name_template: SNAPSHOT-{{ .Commit }}
|
||||
|
||||
checksum:
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||
|
||||
dist: dist
|
||||
18
.goxc.json
18
.goxc.json
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"ArtifactsDest": "target",
|
||||
"TasksExclude": [
|
||||
"pkg-build"
|
||||
],
|
||||
"Arch": "amd64",
|
||||
"Os": "darwin freebsd linux windows",
|
||||
"ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes",
|
||||
"PackageVersion": "1.2.0",
|
||||
"PrereleaseInfo": "rc1",
|
||||
"ConfigVersion": "0.9",
|
||||
"BuildSettings": {
|
||||
"LdFlagsXVars": {
|
||||
"TimeNow": "main.BUILDDATE",
|
||||
"Version": "main.VERSION"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
before_script:
|
||||
- go vet ./...
|
||||
|
||||
go:
|
||||
- 1.7.5
|
||||
- master
|
||||
334
CHANGELOG.md
334
CHANGELOG.md
@@ -4,15 +4,267 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
[1.2.0-rc1] - 2017-01-29
|
||||
------------------------
|
||||
## [v3.1.0-beta1] - 2023-02-28
|
||||
|
||||
### Added
|
||||
- Monitor tab updates when messages are deleted (#337)
|
||||
- Initial framework for extensions
|
||||
- Initial Lua scripting implementation, supporting events:
|
||||
- after.message_deleted
|
||||
- after.message_stored
|
||||
- before.mail_accepted
|
||||
- Provide `http` and `json` modules for Lua scripts
|
||||
|
||||
### Fixed
|
||||
- Support for IP address as domain in RCPT TO (#285)
|
||||
|
||||
|
||||
## [v3.0.4] - 2022-10-02
|
||||
|
||||
### Fixed
|
||||
- More flexible support of `AUTH=<>` FROM parameter (#291)
|
||||
|
||||
|
||||
## [v3.0.3] - 2022-08-07
|
||||
|
||||
### Fixed
|
||||
- Support for `AUTH=<>` FROM parameter (#284)
|
||||
|
||||
|
||||
## [v3.0.2] - 2022-07-04
|
||||
|
||||
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
|
||||
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
|
||||
details.
|
||||
|
||||
### Changed
|
||||
- arm Docker builds now rely on amd64 frontend build stage
|
||||
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
|
||||
|
||||
|
||||
## [v3.0.1-rc2] - 2022-01-23
|
||||
|
||||
### Added
|
||||
- Builds for arm7 and arm64 platforms
|
||||
|
||||
### Changed
|
||||
- Abandoned git-flow process, the `master` branch renamed to `main`
|
||||
|
||||
|
||||
## [v3.0.1-rc1] - 2022-01-17
|
||||
|
||||
### Fixed
|
||||
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
|
||||
|
||||
### Changed
|
||||
- Update Go dependencies
|
||||
- Update NPM dependencies
|
||||
|
||||
|
||||
## [v3.0.0] - 2021-09-19
|
||||
|
||||
Unchanged from rc4.
|
||||
|
||||
|
||||
## [v3.0.0-rc4] - 2021-08-22
|
||||
|
||||
### Fixed
|
||||
- Various MIME header decoding improvements
|
||||
|
||||
### Changed
|
||||
- Bump Go version to 1.17 (#233)
|
||||
|
||||
|
||||
## v3.0.0-rc3 - 2021-08-01
|
||||
|
||||
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
|
||||
tags for Docker Hub and ghcr.io.
|
||||
|
||||
|
||||
## [v3.0.0-rc2] - 2021-07-31
|
||||
|
||||
### Added
|
||||
- Support for SMTP AUTH (#197, thanks makarchuk)
|
||||
- Dark mode support (#218, thanks nerones)
|
||||
|
||||
### Fixed
|
||||
- Prevent potential click jacking (#190, thanks stuartskelton)
|
||||
- Error on 8 character long SMTP commands (#221)
|
||||
- Allow empty username and password during AUTH (#225)
|
||||
|
||||
|
||||
## [v3.0.0-rc1] - 2020-09-24
|
||||
|
||||
### 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] - 2020-09-04
|
||||
|
||||
### 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] - 2019-08-17
|
||||
|
||||
### 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] - 2019-03-14
|
||||
|
||||
### 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 - 2018-12-15
|
||||
|
||||
No change from beta1.
|
||||
|
||||
|
||||
## [v2.1.0-beta1] - 2018-10-31
|
||||
|
||||
### 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/jhillyerd/inbucket/wiki/REST-GET-message)
|
||||
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
|
||||
- [Go client for REST
|
||||
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
|
||||
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
|
||||
@@ -31,8 +283,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Allow increased local-part length of 128 chars for Mailgun
|
||||
- RedHat and Ubuntu now use systemd instead of legacy init systems
|
||||
|
||||
[1.1.0] - 2016-09-03
|
||||
--------------------
|
||||
|
||||
## [v1.1.0] - 2016-09-03
|
||||
|
||||
### Added
|
||||
- Homebrew inbucket.conf and formula (see README)
|
||||
@@ -40,8 +292,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Fixed
|
||||
- Log and continue when unable to delete oldest message during cap enforcement
|
||||
|
||||
[1.1.0-rc2] - 2016-03-06
|
||||
------------------------
|
||||
|
||||
## [v1.1.0-rc2] - 2016-03-06
|
||||
|
||||
### Added
|
||||
- Message Cap to status page
|
||||
@@ -51,8 +303,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Shutdown hang in retention scanner
|
||||
- Display empty subject as `(No Subject)`
|
||||
|
||||
[1.1.0-rc1] - 2016-03-04
|
||||
------------------------
|
||||
|
||||
## [v1.1.0-rc1] - 2016-03-04
|
||||
|
||||
### Added
|
||||
- Inbucket now builds with Go 1.5 or 1.6
|
||||
@@ -66,8 +318,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- RESTful API moved to `/api/v1` base URI
|
||||
- More graceful shutdown on Ctrl-C or when errors encountered
|
||||
|
||||
[1.0] - 2014-04-14
|
||||
------------------
|
||||
|
||||
## [v1.0] - 2014-04-14
|
||||
|
||||
### Added
|
||||
- Add new configuration option `mailbox.message.cap` to prevent individual
|
||||
@@ -75,28 +327,48 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
||||
[1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...main
|
||||
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
|
||||
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
|
||||
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
|
||||
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
|
||||
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
|
||||
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
|
||||
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
|
||||
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
|
||||
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
|
||||
[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
|
||||
-----------------
|
||||
## Release Checklist
|
||||
|
||||
1. Create release branch: `git flow release start 1.x.0`
|
||||
1. Create a release branch
|
||||
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. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=snapshot`
|
||||
4. Run: `goxc interpolate-source` to update VERSION var
|
||||
5. Run tests
|
||||
6. Test cross-compile: `goxc`
|
||||
7. Commit changes and merge release: `git flow release finish 1.x.0`
|
||||
8. Upload to bintray: `goxc bintray`
|
||||
9. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
- Ensure *Unreleased* section is up to date
|
||||
- Rename *Unreleased* section to release name and date
|
||||
- Add new GitHub `/compare` link
|
||||
- Update previous tag version for *Unreleased*
|
||||
3. Run tests
|
||||
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release into main, tag `vX.Y.Z`
|
||||
6. Push tags and wait for
|
||||
[GitHub actions](https://github.com/inbucket/inbucket/actions) 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.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
How to Contribute
|
||||
=================
|
||||
# How to Contribute
|
||||
|
||||
Inbucket encourages third-party patches. It's valuable to know how other
|
||||
developers are using the product.
|
||||
@@ -15,28 +14,18 @@ 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`.
|
||||
Inbucket follows the regular GitHub pattern. Create a topic branch from where
|
||||
you want to base your work:
|
||||
|
||||
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 ./...`
|
||||
3. Run `make` to test, vet and confirm your code is formatted correctly.
|
||||
If you do not have Make installed, please perform these steps manually,
|
||||
otherwise your PR will not pass our checks.
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
Thank you for contributing to Inbucket!
|
||||
|
||||
[git-flow]: https://github.com/nvie/gitflow
|
||||
|
||||
70
Dockerfile
70
Dockerfile
@@ -1,25 +1,57 @@
|
||||
# Docker build file for Inbucket, see https://www.docker.io/
|
||||
# Inbucket website: http://www.inbucket.org/
|
||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||
|
||||
FROM golang:1.7-alpine
|
||||
MAINTAINER James Hillyerd, @jameshillyerd
|
||||
### Build frontend
|
||||
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||
FROM --platform=linux/amd64 node:16 as frontend
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
WORKDIR /build/ui
|
||||
RUN rm -rf .parcel-cache dist elm-stuff node_modules
|
||||
RUN yarn install --frozen-lockfile --non-interactive
|
||||
RUN yarn run build
|
||||
|
||||
# Configuration (WORKDIR doesn't support env vars)
|
||||
ENV INBUCKET_SRC $GOPATH/src/github.com/jhillyerd/inbucket
|
||||
ENV INBUCKET_HOME /opt/inbucket
|
||||
WORKDIR $INBUCKET_HOME
|
||||
ENTRYPOINT ["/con/context/start-inbucket.sh"]
|
||||
CMD ["/con/configuration/inbucket.conf"]
|
||||
### Build backend
|
||||
FROM golang:1.19-alpine3.17 as backend
|
||||
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
RUN make clean deps
|
||||
RUN go build -o inbucket \
|
||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
### Run in minimal image
|
||||
FROM alpine:3.17
|
||||
RUN apk --no-cache add tzdata
|
||||
WORKDIR /opt/inbucket
|
||||
RUN mkdir bin defaults ui
|
||||
COPY --from=backend /build/inbucket bin
|
||||
COPY --from=frontend /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 10025 10080 10110
|
||||
EXPOSE 2500 9000 1100
|
||||
|
||||
# Persistent Volumes, following convention at:
|
||||
# https://github.com/docker/docker/issues/9277
|
||||
# NOTE /con/context is also used, not exposed by default
|
||||
VOLUME /con/configuration
|
||||
VOLUME /con/data
|
||||
# Persistent Volumes
|
||||
VOLUME /config
|
||||
VOLUME /storage
|
||||
|
||||
# Build Inbucket
|
||||
COPY . $INBUCKET_SRC/
|
||||
RUN "$INBUCKET_SRC/etc/docker/install.sh"
|
||||
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/% $(SRC)
|
||||
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'
|
||||
82
README.md
82
README.md
@@ -1,16 +1,21 @@
|
||||
Inbucket
|
||||
=============================================================================
|
||||
[][Build Status]
|
||||

|
||||

|
||||
|
||||
# Inbucket
|
||||
|
||||
Inbucket is an email testing service; it will accept messages for any email
|
||||
address and make them available via web, REST and POP3. Once compiled,
|
||||
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
||||
are all built in).
|
||||
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.
|
||||
|
||||
A Go client for the REST API is available in
|
||||
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
|
||||
|
||||
Read more at the [Inbucket Website]
|
||||
|
||||

|
||||
|
||||
|
||||
## Development Status
|
||||
|
||||
Inbucket is currently production quality: it is being used for real work.
|
||||
@@ -19,45 +24,66 @@ Please see the [Change Log] and [Issues List] for more details. If you'd like
|
||||
to contribute code to the project check out [CONTRIBUTING.md].
|
||||
|
||||
|
||||
## Homebrew Tap
|
||||
## Docker
|
||||
|
||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||
see the `README.md` there for installation instructions.
|
||||
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
|
||||
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||
`main` branch.
|
||||
|
||||
|
||||
## Building from Source
|
||||
|
||||
You will need a functioning [Go installation][Google Go] for this to work.
|
||||
You will need functioning [Go] and [Node.js] installations for this to work.
|
||||
|
||||
Grab the Inbucket source code and compile the daemon:
|
||||
```sh
|
||||
git clone https://github.com/inbucket/inbucket.git
|
||||
cd inbucket/ui
|
||||
yarn install
|
||||
yarn build
|
||||
cd ..
|
||||
go build ./cmd/inbucket
|
||||
```
|
||||
|
||||
go get -v github.com/jhillyerd/inbucket
|
||||
For more information on building and development flows, check out the
|
||||
[Development Quickstart] page of our wiki.
|
||||
|
||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
||||
Unix and OS X machines as is. Launch the daemon:
|
||||
### Configure and Launch
|
||||
|
||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
|
||||
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
|
||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||
|
||||
The Inbucket website has a more complete guide to
|
||||
[installing from source][From Source]
|
||||
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]
|
||||
Inbucket is written in [Go] and [Elm].
|
||||
|
||||
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
|
||||
|
||||
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
||||
[Google Go]: http://golang.org/
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
|
||||
[Inbucket Website]: http://www.inbucket.org/
|
||||
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
|
||||
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
|
||||
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
|
||||
[Configurator]: https://www.inbucket.org/configurator/
|
||||
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/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
|
||||
}
|
||||
222
cmd/inbucket/main.go
Normal file
222
cmd/inbucket/main.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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/server"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||
"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 and start internal services.
|
||||
svcCtx, svcCancel := context.WithCancel(context.Background())
|
||||
services, err := server.FullAssembly(conf)
|
||||
if err != nil {
|
||||
startupLog.Fatal().Err(err).Msg("Fatal error during startup")
|
||||
removePIDFile(*pidfile)
|
||||
}
|
||||
services.Start(svcCtx, func() {
|
||||
startupLog.Debug().Msg("All services report ready")
|
||||
})
|
||||
|
||||
// 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")
|
||||
svcCancel()
|
||||
break signalLoop
|
||||
case syscall.SIGTERM:
|
||||
// Shutdown requested
|
||||
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
|
||||
Msg("Received SIGTERM, shutting down")
|
||||
svcCancel()
|
||||
break signalLoop
|
||||
}
|
||||
case <-services.Notify():
|
||||
log.Info().Str("phase", "shutdown").Msg("Shutting down due to service failure")
|
||||
svcCancel()
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish.
|
||||
go timedExit(*pidfile)
|
||||
log.Debug().Str("phase", "shutdown").Msg("Draining SMTP connections")
|
||||
services.SMTPServer.Drain()
|
||||
log.Debug().Str("phase", "shutdown").Msg("Draining POP3 connections")
|
||||
services.POP3Server.Drain()
|
||||
log.Debug().Str("phase", "shutdown").Msg("Checking retention scanner is stopped")
|
||||
services.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)
|
||||
}
|
||||
270
config/config.go
270
config/config.go
@@ -1,270 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/robfig/config"
|
||||
)
|
||||
|
||||
// SMTPConfig contains the SMTP server configuration - not using pointers so that we 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
|
||||
}
|
||||
|
||||
// POP3Config contains the POP3 server configuration
|
||||
type POP3Config struct {
|
||||
IP4address net.IP
|
||||
IP4port int
|
||||
Domain string
|
||||
MaxIdleSeconds int
|
||||
}
|
||||
|
||||
// WebConfig contains the HTTP server configuration
|
||||
type WebConfig struct {
|
||||
IP4address net.IP
|
||||
IP4port int
|
||||
TemplateDir string
|
||||
TemplateCache bool
|
||||
PublicDir string
|
||||
GreetingFile string
|
||||
MailboxPrompt string
|
||||
CookieAuthKey string
|
||||
MonitorVisible bool
|
||||
MonitorHistory int
|
||||
}
|
||||
|
||||
// DataStoreConfig contains the mail store configuration
|
||||
type DataStoreConfig struct {
|
||||
Path string
|
||||
RetentionMinutes int
|
||||
RetentionSleep int
|
||||
MailboxMsgCap int
|
||||
}
|
||||
|
||||
const (
|
||||
missingErrorFmt = "[%v] missing required option %q"
|
||||
parseErrorFmt = "[%v] option %q error: %v"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version of this build, set by main
|
||||
Version = ""
|
||||
|
||||
// BuildDate for this build, set by main
|
||||
BuildDate = ""
|
||||
|
||||
// Config is our global robfig/config object
|
||||
Config *config.Config
|
||||
logLevel string
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// GetLogLevel returns the configured log level
|
||||
func GetLogLevel() string {
|
||||
return logLevel
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Validation error messages
|
||||
messages := make([]string, 0)
|
||||
// Validate sections
|
||||
for _, s := range []string{"logging", "smtp", "pop3", "web", "datastore"} {
|
||||
if !Config.HasSection(s) {
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Config section [%v] is required", s))
|
||||
}
|
||||
}
|
||||
// Return immediately if config is missing entire sections
|
||||
if len(messages) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
||||
for _, m := range messages {
|
||||
fmt.Fprintln(os.Stderr, " -", m)
|
||||
}
|
||||
return fmt.Errorf("Failed to validate configuration")
|
||||
}
|
||||
// Load string config options
|
||||
stringOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *string
|
||||
required bool
|
||||
}{
|
||||
{"logging", "level", &logLevel, true},
|
||||
{"smtp", "domain", &smtpConfig.Domain, true},
|
||||
{"smtp", "domain.nostore", &smtpConfig.DomainNoStore, false},
|
||||
{"pop3", "domain", &pop3Config.Domain, true},
|
||||
{"web", "template.dir", &webConfig.TemplateDir, true},
|
||||
{"web", "public.dir", &webConfig.PublicDir, true},
|
||||
{"web", "greeting.file", &webConfig.GreetingFile, true},
|
||||
{"web", "mailbox.prompt", &webConfig.MailboxPrompt, false},
|
||||
{"web", "cookie.auth.key", &webConfig.CookieAuthKey, false},
|
||||
{"datastore", "path", &dataStoreConfig.Path, true},
|
||||
}
|
||||
for _, opt := range stringOptions {
|
||||
str, err := Config.String(opt.section, opt.name)
|
||||
if Config.HasOption(opt.section, opt.name) && err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
continue
|
||||
}
|
||||
if str == "" && opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
*opt.target = str
|
||||
}
|
||||
// Load boolean config options
|
||||
boolOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *bool
|
||||
required bool
|
||||
}{
|
||||
{"smtp", "store.messages", &smtpConfig.StoreMessages, true},
|
||||
{"web", "template.cache", &webConfig.TemplateCache, true},
|
||||
{"web", "monitor.visible", &webConfig.MonitorVisible, true},
|
||||
}
|
||||
for _, opt := range boolOptions {
|
||||
if Config.HasOption(opt.section, opt.name) {
|
||||
flag, err := Config.Bool(opt.section, opt.name)
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
}
|
||||
*opt.target = flag
|
||||
} else {
|
||||
if opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load integer config options
|
||||
intOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *int
|
||||
required bool
|
||||
}{
|
||||
{"smtp", "ip4.port", &smtpConfig.IP4port, true},
|
||||
{"smtp", "max.recipients", &smtpConfig.MaxRecipients, true},
|
||||
{"smtp", "max.idle.seconds", &smtpConfig.MaxIdleSeconds, true},
|
||||
{"smtp", "max.message.bytes", &smtpConfig.MaxMessageBytes, true},
|
||||
{"pop3", "ip4.port", &pop3Config.IP4port, true},
|
||||
{"pop3", "max.idle.seconds", &pop3Config.MaxIdleSeconds, true},
|
||||
{"web", "ip4.port", &webConfig.IP4port, true},
|
||||
{"web", "monitor.history", &webConfig.MonitorHistory, true},
|
||||
{"datastore", "retention.minutes", &dataStoreConfig.RetentionMinutes, true},
|
||||
{"datastore", "retention.sleep.millis", &dataStoreConfig.RetentionSleep, true},
|
||||
{"datastore", "mailbox.message.cap", &dataStoreConfig.MailboxMsgCap, true},
|
||||
}
|
||||
for _, opt := range intOptions {
|
||||
if Config.HasOption(opt.section, opt.name) {
|
||||
num, err := Config.Int(opt.section, opt.name)
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
}
|
||||
*opt.target = num
|
||||
} else {
|
||||
if opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load IP address config options
|
||||
ipOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *net.IP
|
||||
required bool
|
||||
}{
|
||||
{"smtp", "ip4.address", &smtpConfig.IP4address, true},
|
||||
{"pop3", "ip4.address", &pop3Config.IP4address, true},
|
||||
{"web", "ip4.address", &webConfig.IP4address, true},
|
||||
}
|
||||
for _, opt := range ipOptions {
|
||||
if Config.HasOption(opt.section, opt.name) {
|
||||
str, err := Config.String(opt.section, opt.name)
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
continue
|
||||
}
|
||||
addr := net.ParseIP(str)
|
||||
if addr == nil {
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Failed to parse IP [%v]%v: %q", opt.section, opt.name, str))
|
||||
continue
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Failed to parse IP [%v]%v: %q not IPv4!",
|
||||
opt.section, opt.name, str))
|
||||
}
|
||||
*opt.target = addr
|
||||
} else {
|
||||
if opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate log level
|
||||
switch strings.ToUpper(logLevel) {
|
||||
case "":
|
||||
// Missing was already reported
|
||||
case "TRACE", "INFO", "WARN", "ERROR":
|
||||
default:
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Invalid value provided for [logging]level: %q", logLevel))
|
||||
}
|
||||
// Print messages and return error if any validations failed
|
||||
if len(messages) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
||||
sort.Strings(messages)
|
||||
for _, m := range messages {
|
||||
fmt.Fprintln(os.Stderr, " -", m)
|
||||
}
|
||||
return fmt.Errorf("Failed to validate configuration")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
472
doc/config.md
Normal file
472
doc/config.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# 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_LUA_PATH inbucket.lua Lua script path
|
||||
INBUCKET_MAILBOXNAMING local Use local, full, or domain 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`
|
||||
|
||||
### Lua Script
|
||||
|
||||
`INBUCKET_LUA_PATH`
|
||||
|
||||
This is the path to the (optional) Inbucket Lua script. If the specified file
|
||||
is present, Inbucket will load it during startup. Ignored if the file is not
|
||||
found, or the setting is empty.
|
||||
|
||||
- Default: `inbucket.lua`
|
||||
|
||||
### 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
|
||||
36
etc/dev-start.sh
Executable file
36
etc/dev-start.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# dev-start.sh
|
||||
# description: Developer friendly Inbucket configuration
|
||||
|
||||
export INBUCKET_LOGLEVEL="debug"
|
||||
#export INBUCKET_MAILBOXNAMING="domain"
|
||||
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 $*
|
||||
129
etc/devel.conf
129
etc/devel.conf
@@ -1,129 +0,0 @@
|
||||
# devel.conf
|
||||
# Sample development configuration
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=.
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[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=%(default.domain)s
|
||||
|
||||
# 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=%(default.domain)s
|
||||
|
||||
# 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=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# 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
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[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
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=100
|
||||
@@ -1,7 +1,9 @@
|
||||
<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 email for a particular address, enter the username portion
|
||||
<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
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# inbucket.conf
|
||||
# Configuration for Inbucket inside of Docker
|
||||
#
|
||||
# These should be reasonable defaults for a production install of Inbucket
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=/opt/inbucket
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[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=10025
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=%(default.domain)s
|
||||
|
||||
# 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=10110
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=%(default.domain)s
|
||||
|
||||
# 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=10080
|
||||
|
||||
# Name of web theme to use
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# 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=/con/configuration/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
# Path to the datastore, mail will be written into subdirectories
|
||||
path=/con/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=4320
|
||||
|
||||
# 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
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=300
|
||||
@@ -2,8 +2,9 @@
|
||||
# start-inbucket.sh
|
||||
# description: start inbucket (runs within a docker container)
|
||||
|
||||
INBUCKET_HOME="/opt/inbucket"
|
||||
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
||||
CONF_TARGET="/con/configuration"
|
||||
CONF_TARGET="/config"
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
@@ -18,7 +19,6 @@ install_default_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_default_config "inbucket.conf"
|
||||
install_default_config "greeting.html"
|
||||
|
||||
exec "$INBUCKET_HOME/bin/inbucket" $*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# description: Launch Inbucket's docker image
|
||||
|
||||
# Docker Image Tag
|
||||
IMAGE="jhillyerd/inbucket"
|
||||
IMAGE="inbucket/inbucket:edge"
|
||||
|
||||
# Ports exposed on host:
|
||||
PORT_HTTP=9000
|
||||
@@ -12,9 +12,9 @@ PORT_POP3=1100
|
||||
|
||||
# Volumes exposed on host:
|
||||
VOL_CONFIG="/tmp/inbucket/config"
|
||||
VOL_DATA="/tmp/inbucket/data"
|
||||
VOL_DATA="/tmp/inbucket/storage"
|
||||
|
||||
set -eo pipefail
|
||||
set -e
|
||||
|
||||
main() {
|
||||
local run_opts=""
|
||||
@@ -39,11 +39,11 @@ main() {
|
||||
done
|
||||
|
||||
docker run $run_opts \
|
||||
-p $PORT_HTTP:10080 \
|
||||
-p $PORT_SMTP:10025 \
|
||||
-p $PORT_POP3:10110 \
|
||||
-v "$VOL_CONFIG:/con/configuration" \
|
||||
-v "$VOL_DATA:/con/data" \
|
||||
-p $PORT_HTTP:9000 \
|
||||
-p $PORT_SMTP:2500 \
|
||||
-p $PORT_POP3:1100 \
|
||||
-v "$VOL_CONFIG:/config" \
|
||||
-v "$VOL_DATA:/storage" \
|
||||
"$IMAGE"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/bin/sh
|
||||
# install.sh
|
||||
# description: Build, test, and install Inbucket. Should be executed inside a Docker container.
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
installdir="$INBUCKET_HOME"
|
||||
srcdir="$INBUCKET_SRC"
|
||||
bindir="$installdir/bin"
|
||||
defaultsdir="$installdir/defaults"
|
||||
contextdir="/con/context"
|
||||
|
||||
echo "### Installing OS Build Dependencies"
|
||||
apk add --no-cache --virtual .build-deps git
|
||||
|
||||
# Setup
|
||||
export GOBIN="$bindir"
|
||||
builddate="$(date -Iseconds)"
|
||||
cd "$srcdir"
|
||||
go clean
|
||||
|
||||
# Build
|
||||
echo "### Fetching Dependencies"
|
||||
go get -t -v ./...
|
||||
|
||||
echo "### Testing Inbucket"
|
||||
go test ./...
|
||||
|
||||
echo "### Building Inbucket"
|
||||
go build -o inbucket -ldflags "-X 'main.BUILDDATE=$builddate'" -v .
|
||||
|
||||
echo "### Installing Inbucket"
|
||||
set -x
|
||||
mkdir -p "$bindir"
|
||||
install inbucket "$bindir"
|
||||
mkdir -p "$contextdir"
|
||||
install etc/docker/defaults/start-inbucket.sh "$contextdir"
|
||||
cp -r themes "$installdir/"
|
||||
mkdir -p "$defaultsdir"
|
||||
cp etc/docker/defaults/inbucket.conf "$defaultsdir"
|
||||
cp etc/docker/defaults/greeting.html "$defaultsdir"
|
||||
set +x
|
||||
|
||||
echo "### Removing OS Build Dependencies"
|
||||
apk del .build-deps
|
||||
|
||||
echo "### Removing $GOPATH"
|
||||
rm -rf "$GOPATH"
|
||||
@@ -1,131 +0,0 @@
|
||||
# inbucket.conf
|
||||
# homebrew inbucket configuration
|
||||
# {{}} values will be replaced during installation
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
default.domain=inbucket.local
|
||||
themes.dir={{HOMEBREW_PREFIX}}/share/inbucket/themes
|
||||
datastore.dir={{HOMEBREW_PREFIX}}/var/inbucket/datastore
|
||||
|
||||
#############################################################################
|
||||
[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=%(default.domain)s
|
||||
|
||||
# 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=%(default.domain)s
|
||||
|
||||
# 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=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(themes.dir)s/%(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=%(themes.dir)s/%(theme)s/public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=%(themes.dir)s/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
# Path to the datastore, mail will be written into subdirectories
|
||||
path=%(datastore.dir)s
|
||||
|
||||
# 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=10080
|
||||
|
||||
# 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
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=100
|
||||
@@ -1,129 +0,0 @@
|
||||
# inbucket.conf
|
||||
# Sample inbucket configuration
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=.
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[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=%(default.domain)s
|
||||
|
||||
# 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=%(default.domain)s
|
||||
|
||||
# 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=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# 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
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[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
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=500
|
||||
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,3 +0,0 @@
|
||||
Please see the RedHat installation guide on our website:
|
||||
|
||||
http://www.inbucket.org/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
|
||||
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
|
||||
endscript
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=Inbucket Disposable Email Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=inbucket
|
||||
Group=inbucket
|
||||
|
||||
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
|
||||
|
||||
# Re-open log file after rotation
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
# Give SMTP connections time to drain
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# rest-apiv1.sh
|
||||
# description: Script to access Inbucket REST API version 1
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
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>
|
||||
@@ -1,7 +1,7 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: tutsplus responsive
|
||||
Subject: tutsplus responsive external CSS
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
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>
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
# run-tests.sh
|
||||
# description: Generate test emails for Inbucket
|
||||
|
||||
@@ -24,7 +24,7 @@ case "$1" in
|
||||
;;
|
||||
esac
|
||||
|
||||
export SWAKS_OPT_server="127.0.0.1:2500"
|
||||
export SWAKS_OPT_server="${SWAKS_OPT_server:-127.0.0.1:2500}"
|
||||
export SWAKS_OPT_to="$to@inbucket.local"
|
||||
|
||||
# Basic test
|
||||
@@ -53,5 +53,13 @@ swaks $* --data gmail.raw
|
||||
# Outlook test
|
||||
swaks $* --data outlook.raw
|
||||
|
||||
# Nonemime responsive HTML test
|
||||
# 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
|
||||
|
||||
# IP RCPT domain
|
||||
swaks $* --to="swaks@[127.0.0.1]" --h-Subject: "IPv4 RCPT Address" --body text.txt
|
||||
swaks $* --to="swaks@[IPv6:2001:db8:aaaa:1::100]" --h-Subject: "IPv6 RCPT Address" --body text.txt
|
||||
@@ -1,6 +1,8 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
To: %TO_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?=
|
||||
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
||||
@@ -1,3 +0,0 @@
|
||||
Please see the Ubuntu installation guide on our website:
|
||||
|
||||
http://www.inbucket.org/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 /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
|
||||
endscript
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=Inbucket Disposable Email Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=inbucket
|
||||
Group=inbucket
|
||||
|
||||
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
|
||||
|
||||
# Re-open log file after rotation
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
# Give SMTP connections time to drain
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,129 +0,0 @@
|
||||
# inbucket.conf
|
||||
# Sample inbucket configuration
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=/opt/inbucket
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[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=%(default.domain)s
|
||||
|
||||
# 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=%(default.domain)s
|
||||
|
||||
# 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=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# 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
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[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
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=500
|
||||
@@ -1,129 +0,0 @@
|
||||
# win-sample.conf
|
||||
# Sample inbucket configuration for Windows
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=.
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[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=%(default.domain)s
|
||||
|
||||
# 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=%(default.domain)s
|
||||
|
||||
# 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=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# 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
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[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
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=500
|
||||
42
go.mod
Normal file
42
go.mod
Normal file
@@ -0,0 +1,42 @@
|
||||
module github.com/inbucket/inbucket
|
||||
|
||||
require (
|
||||
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
||||
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.5.0
|
||||
github.com/inbucket/gopher-json v0.2.0
|
||||
github.com/jhillyerd/enmime v0.11.0
|
||||
github.com/jhillyerd/goldiff v0.1.0
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.22
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/stretchr/testify v1.8.2
|
||||
github.com/yuin/gopher-lua v1.1.0
|
||||
golang.org/x/net v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/go-test/deep v1.1.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
go 1.18
|
||||
124
go.sum
Normal file
124
go.sum
Normal file
@@ -0,0 +1,124 @@
|
||||
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/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9 h1:rdWOzitWlNYeUsXmz+IQfa9NkGEq3gA/qQ3mOEqBU6o=
|
||||
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9/go.mod h1:X97UjDTXp+7bayQSFZk2hPvCTmTZIicUjZQRtkwgAKY=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/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.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inbucket/gopher-json v0.2.0 h1:v/luoFy5olitFhByVUGMZ3LmtcroRs9YHlyrBedz7EA=
|
||||
github.com/inbucket/gopher-json v0.2.0/go.mod h1:1BK2XgU9y+ibiRkylJQeV44AV9DrO8dVsgOJ6vpqF3g=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.11.0 h1:5EOSLh7l3eMODznfMCKVGQY74Qb95Yfuet6CYgquLfM=
|
||||
github.com/jhillyerd/enmime v0.11.0/go.mod h1:nw2aJ34YXWklLze+qEESgP+KNhU3fMQuiFsD/4soh3Q=
|
||||
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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.22 h1:p2tT7RNzRdCi0qmwxG+HbqD6ILkmwter1ZwVZn1oTxA=
|
||||
github.com/microcosm-cc/bluemonday v1.0.22/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
|
||||
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,64 +0,0 @@
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"friendlyTime": FriendlyTime,
|
||||
"reverse": Reverse,
|
||||
"textToHtml": TextToHTML,
|
||||
}
|
||||
|
||||
// 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`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
||||
|
||||
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
|
||||
// otherwise Mon Jan 2, 2006
|
||||
func FriendlyTime(t time.Time) template.HTML {
|
||||
ty, tm, td := t.Date()
|
||||
ny, nm, nd := time.Now().Date()
|
||||
if (ty == ny) && (tm == nm) && (td == nd) {
|
||||
return template.HTML(t.Format("03:04:05 PM"))
|
||||
}
|
||||
return template.HTML(t.Format("Mon Jan 2, 2006"))
|
||||
}
|
||||
|
||||
// Reverse routing function (shared with templates)
|
||||
func Reverse(name string, things ...interface{}) string {
|
||||
// Convert the things to strings
|
||||
strs := make([]string, len(things))
|
||||
for i, th := range things {
|
||||
strs[i] = fmt.Sprint(th)
|
||||
}
|
||||
// Grab the route
|
||||
u, err := Router.Get(name).URL(strs...)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to reverse route: %v", err)
|
||||
return "/ROUTE-ERROR"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||
// HTML display
|
||||
func TextToHTML(text string) template.HTML {
|
||||
text = html.EscapeString(text)
|
||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||
return template.HTML(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)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextToHtml(t *testing.T) {
|
||||
// Identity
|
||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
||||
|
||||
// Check it escapes
|
||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
||||
|
||||
// Check for linebreaks
|
||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
||||
}
|
||||
|
||||
func TestURLDetection(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://google.com/"),
|
||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
||||
assert.Equal(t,
|
||||
TextToHTML("http://a.com/?q=a&n=v"),
|
||||
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>"))
|
||||
}
|
||||
159
httpd/server.go
159
httpd/server.go
@@ -1,159 +0,0 @@
|
||||
// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
var (
|
||||
// DataStore is where all the mailboxes and messages live
|
||||
DataStore smtpd.DataStore
|
||||
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
|
||||
// Router is shared between httpd, webui and rest packages. It sends
|
||||
// incoming requests to the correct handler function
|
||||
Router = mux.NewRouter()
|
||||
|
||||
webConfig config.WebConfig
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
sessionStore sessions.Store
|
||||
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(
|
||||
cfg config.WebConfig,
|
||||
shutdownChan chan bool,
|
||||
ds smtpd.DataStore,
|
||||
mh *msghub.Hub) {
|
||||
|
||||
webConfig = cfg
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers
|
||||
DataStore = ds
|
||||
msgHub = mh
|
||||
|
||||
// Content Paths
|
||||
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
|
||||
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(cfg.PublicDir))))
|
||||
http.Handle("/", Router)
|
||||
|
||||
// Session cookie setup
|
||||
if cfg.CookieAuthKey == "" {
|
||||
log.Infof("HTTP generating random cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||
} else {
|
||||
log.Tracef("HTTP using configured cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey))
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
|
||||
server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: nil,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// We don't use ListenAndServe because it lacks a way to close the listener
|
||||
log.Infof("HTTP listening on TCP4 %v", addr)
|
||||
var err error
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
|
||||
emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
log.Tracef("HTTP server shutting down on request")
|
||||
}
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit
|
||||
if err := listener.Close(); err != nil {
|
||||
log.Errorf("Failed to close HTTP listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.Errorf("HTTP server failed: %v", err)
|
||||
emergencyShutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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.Errorf("HTTP failed to create context: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it
|
||||
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
default:
|
||||
close(globalShutdown)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
var cachedMutex sync.Mutex
|
||||
var cachedTemplates = map[string]*template.Template{}
|
||||
var cachedPartials = map[string]*template.Template{}
|
||||
|
||||
// RenderTemplate fetches the named template and renders it to the provided
|
||||
// ResponseWriter.
|
||||
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, false)
|
||||
if err != nil {
|
||||
log.Errorf("Error in template '%v': %v", name, err)
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// RenderPartial fetches the named template and renders it to the provided
|
||||
// ResponseWriter.
|
||||
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, true)
|
||||
if err != nil {
|
||||
log.Errorf("Error in template '%v': %v", name, err)
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
// ParseTemplate loads the requested template along with _base.html, caching
|
||||
// the result (if configured to do so)
|
||||
func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
||||
cachedMutex.Lock()
|
||||
defer cachedMutex.Unlock()
|
||||
|
||||
if t, ok := cachedTemplates[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
|
||||
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
|
||||
log.Tracef("Parsing template %v", tempFile)
|
||||
|
||||
var err error
|
||||
var t *template.Template
|
||||
if partial {
|
||||
// Need to get basename of file to make it root template w/ funcs
|
||||
base := path.Base(name)
|
||||
t = template.New(base).Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(tempFile)
|
||||
} else {
|
||||
t = template.New("_base.html").Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Allows us to disable caching for theme development
|
||||
if webConfig.TemplateCache {
|
||||
if partial {
|
||||
log.Tracef("Caching partial %v", name)
|
||||
cachedTemplates[name] = t
|
||||
} else {
|
||||
log.Tracef("Caching template %v", name)
|
||||
cachedTemplates[name] = t
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
inbucket.exe etc\win-sample.conf
|
||||
183
inbucket.go
183
inbucket.go
@@ -1,183 +0,0 @@
|
||||
// main is the inbucket daemon launcher
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/pop3d"
|
||||
"github.com/jhillyerd/inbucket/rest"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"github.com/jhillyerd/inbucket/webui"
|
||||
)
|
||||
|
||||
var (
|
||||
// VERSION contains the build version number, populated during linking by goxc
|
||||
VERSION = "1.2.0-rc1"
|
||||
|
||||
// BUILDDATE contains the build date, populated during linking by goxc
|
||||
BUILDDATE = "undefined"
|
||||
|
||||
// Command line flags
|
||||
help = flag.Bool("help", false, "Displays this help")
|
||||
pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
|
||||
logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
|
||||
|
||||
// shutdownChan - close it to tell Inbucket to shut down cleanly
|
||||
shutdownChan = make(chan bool)
|
||||
|
||||
// Server instances
|
||||
smtpServer *smtpd.Server
|
||||
pop3Server *pop3d.Server
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
// Server uptime for status page
|
||||
startTime := time.Now()
|
||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
||||
return time.Since(startTime) / time.Second
|
||||
}))
|
||||
|
||||
// Goroutine count for status page
|
||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||
return runtime.NumGoroutine()
|
||||
}))
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.Version = VERSION
|
||||
config.BuildDate = BUILDDATE
|
||||
|
||||
flag.Parse()
|
||||
if *help {
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
// Root context
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
|
||||
// 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, syscall.SIGINT)
|
||||
|
||||
// Initialize logging
|
||||
log.SetLogLevel(config.GetLogLevel())
|
||||
if err := log.Initialize(*logfile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer log.Close()
|
||||
|
||||
log.Infof("Inbucket %v (%v) starting...", config.Version, config.BuildDate)
|
||||
|
||||
// Write pidfile if requested
|
||||
if *pidfile != "none" {
|
||||
pidf, err := os.Create(*pidfile)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create %q: %v", *pidfile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(pidf, "%v\n", os.Getpid())
|
||||
if err := pidf.Close(); err != nil {
|
||||
log.Errorf("Failed to close PID file %q: %v", *pidfile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create message hub
|
||||
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
|
||||
|
||||
// Grab our datastore
|
||||
ds := smtpd.DefaultFileDataStore()
|
||||
|
||||
// Start HTTP server
|
||||
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
|
||||
webui.SetupRoutes(httpd.Router)
|
||||
rest.SetupRoutes(httpd.Router)
|
||||
go httpd.Start(rootCtx)
|
||||
|
||||
// Start POP3 server
|
||||
// TODO pass datastore
|
||||
pop3Server = pop3d.New(shutdownChan)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Startup SMTP server
|
||||
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel
|
||||
signalLoop:
|
||||
for {
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
switch sig {
|
||||
case syscall.SIGHUP:
|
||||
log.Infof("Recieved SIGHUP, cycling logfile")
|
||||
log.Rotate()
|
||||
case syscall.SIGINT:
|
||||
// Shutdown requested
|
||||
log.Infof("Received SIGINT, shutting down")
|
||||
close(shutdownChan)
|
||||
case syscall.SIGTERM:
|
||||
// Shutdown requested
|
||||
log.Infof("Received SIGTERM, shutting down")
|
||||
close(shutdownChan)
|
||||
}
|
||||
case _ = <-shutdownChan:
|
||||
rootCancel()
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish
|
||||
go timedExit()
|
||||
smtpServer.Drain()
|
||||
pop3Server.Drain()
|
||||
|
||||
removePIDFile()
|
||||
}
|
||||
|
||||
// removePIDFile removes the PID file if created
|
||||
func removePIDFile() {
|
||||
if *pidfile != "none" {
|
||||
if err := os.Remove(*pidfile); err != nil {
|
||||
log.Errorf("Failed to remove %q: %v", *pidfile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// timedExit is called as a goroutine during shutdown, it will force an exit
|
||||
// after 15 seconds
|
||||
func timedExit() {
|
||||
time.Sleep(15 * time.Second)
|
||||
log.Errorf("Clean shutdown took too long, forcing exit")
|
||||
removePIDFile()
|
||||
os.Exit(0)
|
||||
}
|
||||
145
log/logging.go
145
log/logging.go
@@ -1,145 +0,0 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
golog "log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is used to indicate the severity of a log entry
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// ERROR indicates a significant problem was encountered
|
||||
ERROR Level = iota
|
||||
// WARN indicates something that may be a problem
|
||||
WARN
|
||||
// INFO indicates a purely informational log entry
|
||||
INFO
|
||||
// TRACE entries are meant for development purposes only
|
||||
TRACE
|
||||
)
|
||||
|
||||
var (
|
||||
// MaxLevel is the highest Level we will log (max TRACE, min ERROR)
|
||||
MaxLevel = TRACE
|
||||
|
||||
// logfname is the name of the logfile
|
||||
logfname string
|
||||
|
||||
// logf is the file we send log output to, will be nil for stderr or stdout
|
||||
logf *os.File
|
||||
)
|
||||
|
||||
// Initialize logging. If logfile is equal to "stderr" or "stdout", then
|
||||
// we will log to that output stream. Otherwise the specificed file will
|
||||
// opened for writing, and all log data will be placed in it.
|
||||
func Initialize(logfile string) error {
|
||||
if logfile != "stderr" {
|
||||
// stderr is the go logging default
|
||||
if logfile == "stdout" {
|
||||
// set to stdout
|
||||
golog.SetOutput(os.Stdout)
|
||||
} else {
|
||||
logfname = logfile
|
||||
if err := openLogFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Platform specific
|
||||
closeStdin()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLogLevel sets MaxLevel based on the provided string
|
||||
func SetLogLevel(level string) (ok bool) {
|
||||
switch strings.ToUpper(level) {
|
||||
case "ERROR":
|
||||
MaxLevel = ERROR
|
||||
case "WARN":
|
||||
MaxLevel = WARN
|
||||
case "INFO":
|
||||
MaxLevel = INFO
|
||||
case "TRACE":
|
||||
MaxLevel = TRACE
|
||||
default:
|
||||
Errorf("Unknown log level requested: " + level)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Errorf logs a message to the 'standard' Logger (always), accepts format strings
|
||||
func Errorf(msg string, args ...interface{}) {
|
||||
msg = "[ERROR] " + msg
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
|
||||
// Warnf logs a message to the 'standard' Logger if MaxLevel is >= WARN, accepts format strings
|
||||
func Warnf(msg string, args ...interface{}) {
|
||||
if MaxLevel >= WARN {
|
||||
msg = "[WARN ] " + msg
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Infof logs a message to the 'standard' Logger if MaxLevel is >= INFO, accepts format strings
|
||||
func Infof(msg string, args ...interface{}) {
|
||||
if MaxLevel >= INFO {
|
||||
msg = "[INFO ] " + msg
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Tracef logs a message to the 'standard' Logger if MaxLevel is >= TRACE, accepts format strings
|
||||
func Tracef(msg string, args ...interface{}) {
|
||||
if MaxLevel >= TRACE {
|
||||
msg = "[TRACE] " + msg
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate closes the current log file, then reopens it. This gives an external
|
||||
// log rotation system the opportunity to move the existing log file out of the
|
||||
// way and have Inbucket create a new one.
|
||||
func Rotate() {
|
||||
// Rotate logs if configured
|
||||
if logf != nil {
|
||||
closeLogFile()
|
||||
// There is nothing we can do if the log open fails
|
||||
_ = openLogFile()
|
||||
} else {
|
||||
Infof("Ignoring SIGHUP, logfile not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// Close the log file if we have one open
|
||||
func Close() {
|
||||
if logf != nil {
|
||||
closeLogFile()
|
||||
}
|
||||
}
|
||||
|
||||
// openLogFile creates or appends to the logfile passed on commandline
|
||||
func openLogFile() error {
|
||||
// use specified log file
|
||||
var err error
|
||||
logf, err = os.OpenFile(logfname, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create %v: %v\n", logfname, err)
|
||||
}
|
||||
golog.SetOutput(logf)
|
||||
Tracef("Opened new logfile")
|
||||
// Platform specific
|
||||
reassignStdout()
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeLogFile closes the current logfile
|
||||
func closeLogFile() {
|
||||
Tracef("Closing logfile")
|
||||
// We are never in a situation where we can do anything about failing to close
|
||||
_ = logf.Close()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// +build !windows
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
"os"
|
||||
)
|
||||
|
||||
// closeStdin will close stdin on Unix platforms - this is standard practice
|
||||
// for daemons
|
||||
func closeStdin() {
|
||||
if err := os.Stdin.Close(); err != nil {
|
||||
// Not a fatal error
|
||||
Errorf("Failed to close os.Stdin during log setup")
|
||||
}
|
||||
}
|
||||
|
||||
// reassignStdout points stdout/stderr to our logfile on systems that support
|
||||
// the Dup2 syscall per https://github.com/golang/go/issues/325
|
||||
func reassignStdout() {
|
||||
Tracef("Unix reassignStdout()")
|
||||
if err := unix.Dup2(int(logf.Fd()), 1); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to re-assign stdout to logfile: %v", err)
|
||||
}
|
||||
if err := unix.Dup2(int(logf.Fd()), 2); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to re-assign stderr to logfile: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// +build windows
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var stdOutsClosed = false
|
||||
|
||||
// closeStdin does nothing on Windows, it would always fail
|
||||
func closeStdin() {
|
||||
// Nop
|
||||
}
|
||||
|
||||
// reassignStdout points stdout/stderr to our logfile on systems that do not
|
||||
// support the Dup2 syscall
|
||||
func reassignStdout() {
|
||||
Tracef("Windows reassignStdout()")
|
||||
if !stdOutsClosed {
|
||||
// Close std* streams to prevent accidental output, they will be redirected to
|
||||
// our logfile below
|
||||
|
||||
// Warning: this will hide panic() output, sorry Windows users
|
||||
if err := os.Stderr.Close(); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to close os.Stderr during log setup")
|
||||
}
|
||||
if err := os.Stdin.Close(); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to close os.Stdin during log setup")
|
||||
}
|
||||
os.Stdout = logf
|
||||
os.Stderr = logf
|
||||
stdOutsClosed = true
|
||||
}
|
||||
}
|
||||
108
msghub/hub.go
108
msghub/hub.go
@@ -1,108 +0,0 @@
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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 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
|
||||
}
|
||||
}
|
||||
141
pkg/config/config.go
Normal file
141
pkg/config/config.go
Normal file
@@ -0,0 +1,141 @@
|
||||
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"`
|
||||
Lua Lua
|
||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full, or domain addressing"`
|
||||
SMTP SMTP
|
||||
POP3 POP3
|
||||
Web Web
|
||||
Storage Storage
|
||||
}
|
||||
|
||||
// Lua contains the Lua extension host configuration.
|
||||
type Lua struct {
|
||||
Path string `required:"false" default:"inbucket.lua" desc:"Lua script path"`
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
89
pkg/extension/async_broker.go
Normal file
89
pkg/extension/async_broker.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AsyncEventBroker maintains a list of listeners interested in a specific type
|
||||
// of event. Events are sent in parallel to all listeners, and no result is
|
||||
// returned.
|
||||
type AsyncEventBroker[E any] struct {
|
||||
sync.RWMutex
|
||||
listenerNames []string // Ordered listener names.
|
||||
listenerFuncs []func(E) // Ordered listener functions.
|
||||
}
|
||||
|
||||
// Emit sends the provided event to each registered listener in parallel.
|
||||
func (eb *AsyncEventBroker[E]) Emit(event *E) {
|
||||
eb.RLock()
|
||||
defer eb.RUnlock()
|
||||
|
||||
for _, l := range eb.listenerFuncs {
|
||||
// Events are copied to minimize the risk of mutation.
|
||||
go l(*event)
|
||||
}
|
||||
}
|
||||
|
||||
// AddListener registers the named listener, replacing one with a duplicate
|
||||
// name if present. Listeners should be added in order of priority, most
|
||||
// significant first.
|
||||
func (eb *AsyncEventBroker[E]) AddListener(name string, listener func(E)) {
|
||||
eb.Lock()
|
||||
defer eb.Unlock()
|
||||
|
||||
eb.lockedRemoveListener(name)
|
||||
eb.listenerNames = append(eb.listenerNames, name)
|
||||
eb.listenerFuncs = append(eb.listenerFuncs, listener)
|
||||
}
|
||||
|
||||
// RemoveListener unregisters the named listener.
|
||||
func (eb *AsyncEventBroker[E]) RemoveListener(name string) {
|
||||
eb.Lock()
|
||||
defer eb.Unlock()
|
||||
|
||||
eb.lockedRemoveListener(name)
|
||||
}
|
||||
|
||||
func (eb *AsyncEventBroker[E]) lockedRemoveListener(name string) {
|
||||
for i, entry := range eb.listenerNames {
|
||||
if entry == name {
|
||||
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
|
||||
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AsyncTestListener returns a func that will wait for an event and return it, or timeout
|
||||
// with an error.
|
||||
func (eb *AsyncEventBroker[E]) AsyncTestListener(name string, capacity int) func() (*E, error) {
|
||||
// Send event down channel.
|
||||
events := make(chan E, capacity)
|
||||
eb.AddListener(name,
|
||||
func(msg E) {
|
||||
events <- msg
|
||||
})
|
||||
|
||||
count := 0
|
||||
|
||||
return func() (*E, error) {
|
||||
count++
|
||||
|
||||
defer func() {
|
||||
if count >= capacity {
|
||||
eb.RemoveListener(name)
|
||||
close(events)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case event := <-events:
|
||||
return &event, nil
|
||||
|
||||
case <-time.After(time.Second * 2):
|
||||
return nil, errors.New("Timeout waiting for event")
|
||||
}
|
||||
}
|
||||
}
|
||||
101
pkg/extension/async_broker_test.go
Normal file
101
pkg/extension/async_broker_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package extension_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Simple smoke test without using AsyncTestListener.
|
||||
func TestAsyncBrokerEmitCallsOneListener(t *testing.T) {
|
||||
broker := &extension.AsyncEventBroker[string]{}
|
||||
|
||||
// Setup listener.
|
||||
events := make(chan string, 1)
|
||||
listener := func(s string) {
|
||||
events <- s
|
||||
}
|
||||
broker.AddListener("x", listener)
|
||||
|
||||
want := "bacon"
|
||||
broker.Emit(&want)
|
||||
|
||||
var got string
|
||||
select {
|
||||
case event := <-events:
|
||||
got = event
|
||||
|
||||
case <-time.After(time.Second * 2):
|
||||
t.Fatal("Timeout waiting for event")
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Emit got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncBrokerEmitCallsMultipleListeners(t *testing.T) {
|
||||
broker := &extension.AsyncEventBroker[string]{}
|
||||
|
||||
// Setup listeners.
|
||||
first := broker.AsyncTestListener("first", 1)
|
||||
second := broker.AsyncTestListener("second", 1)
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
|
||||
first_got, err := first()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *first_got)
|
||||
|
||||
second_got, err := second()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *second_got)
|
||||
}
|
||||
|
||||
func TestAsyncBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
|
||||
broker := &extension.AsyncEventBroker[string]{}
|
||||
|
||||
// Setup listeners.
|
||||
first := broker.AsyncTestListener("dup", 1)
|
||||
second := broker.AsyncTestListener("dup", 1)
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
|
||||
first_got, err := first()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, first_got)
|
||||
|
||||
second_got, err := second()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *second_got)
|
||||
}
|
||||
|
||||
func TestAsyncBrokerRemovingListenerSuccessful(t *testing.T) {
|
||||
broker := &extension.AsyncEventBroker[string]{}
|
||||
|
||||
// Setup listeners.
|
||||
first := broker.AsyncTestListener("1", 1)
|
||||
second := broker.AsyncTestListener("2", 1)
|
||||
broker.RemoveListener("1")
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
|
||||
first_got, err := first()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, first_got)
|
||||
|
||||
second_got, err := second()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, *second_got)
|
||||
}
|
||||
|
||||
func TestAsyncBrokerRemovingMissingListener(t *testing.T) {
|
||||
broker := &extension.AsyncEventBroker[string]{}
|
||||
broker.RemoveListener("doesn't crash")
|
||||
}
|
||||
59
pkg/extension/broker.go
Normal file
59
pkg/extension/broker.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EventBroker maintains a list of listeners interested in a specific type
|
||||
// of event.
|
||||
type EventBroker[E any, R comparable] struct {
|
||||
sync.RWMutex
|
||||
listenerNames []string // Ordered listener names.
|
||||
listenerFuncs []func(E) *R // Ordered listener functions.
|
||||
}
|
||||
|
||||
// Emit sends the provided event to each registered listener in order, until
|
||||
// one returns a non-nil result. That result will be returned to the caller.
|
||||
func (eb *EventBroker[E, R]) Emit(event *E) *R {
|
||||
eb.RLock()
|
||||
defer eb.RUnlock()
|
||||
|
||||
for _, l := range eb.listenerFuncs {
|
||||
// Events are copied to minimize the risk of mutation.
|
||||
if result := l(*event); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddListener registers the named listener, replacing one with a duplicate
|
||||
// name if present. Listeners should be added in order of priority, most
|
||||
// significant first.
|
||||
func (eb *EventBroker[E, R]) AddListener(name string, listener func(E) *R) {
|
||||
eb.Lock()
|
||||
defer eb.Unlock()
|
||||
|
||||
eb.lockedRemoveListener(name)
|
||||
eb.listenerNames = append(eb.listenerNames, name)
|
||||
eb.listenerFuncs = append(eb.listenerFuncs, listener)
|
||||
}
|
||||
|
||||
// RemoveListener unregisters the named listener.
|
||||
func (eb *EventBroker[E, R]) RemoveListener(name string) {
|
||||
eb.Lock()
|
||||
defer eb.Unlock()
|
||||
|
||||
eb.lockedRemoveListener(name)
|
||||
}
|
||||
|
||||
func (eb *EventBroker[E, R]) lockedRemoveListener(name string) {
|
||||
for i, entry := range eb.listenerNames {
|
||||
if entry == name {
|
||||
eb.listenerNames = append(eb.listenerNames[:i], eb.listenerNames[i+1:]...)
|
||||
eb.listenerFuncs = append(eb.listenerFuncs[:i], eb.listenerFuncs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
134
pkg/extension/broker_test.go
Normal file
134
pkg/extension/broker_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package extension_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
)
|
||||
|
||||
func TestBrokerEmitCallsOneListener(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
|
||||
// Setup listener.
|
||||
var got string
|
||||
listener := func(s string) *bool {
|
||||
got = s
|
||||
return nil
|
||||
}
|
||||
broker.AddListener("x", listener)
|
||||
|
||||
want := "bacon"
|
||||
broker.Emit(&want)
|
||||
if got != want {
|
||||
t.Errorf("Emit got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerEmitCallsMultipleListeners(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
|
||||
// Setup listeners.
|
||||
var first_got, second_got string
|
||||
first := func(s string) *bool {
|
||||
first_got = s
|
||||
return nil
|
||||
}
|
||||
second := func(s string) *bool {
|
||||
second_got = s
|
||||
return nil
|
||||
}
|
||||
|
||||
broker.AddListener("1", first)
|
||||
broker.AddListener("2", second)
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
if first_got != want {
|
||||
t.Errorf("first got %q, want %q", first_got, want)
|
||||
}
|
||||
if second_got != want {
|
||||
t.Errorf("second got %q, want %q", second_got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerEmitCapturesFirstResult(t *testing.T) {
|
||||
broker := &extension.EventBroker[struct{}, string]{}
|
||||
|
||||
// Setup listeners.
|
||||
makeListener := func(result *string) func(struct{}) *string {
|
||||
return func(s struct{}) *string { return result }
|
||||
}
|
||||
first := "first"
|
||||
second := "second"
|
||||
broker.AddListener("0", makeListener(nil))
|
||||
broker.AddListener("1", makeListener(&first))
|
||||
broker.AddListener("2", makeListener(&second))
|
||||
|
||||
want := first
|
||||
got := broker.Emit(&struct{}{})
|
||||
if got == nil {
|
||||
t.Errorf("Emit got nil, want %q", want)
|
||||
} else if *got != want {
|
||||
t.Errorf("Emit got %q, want %q", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerAddingDuplicateNameReplacesPrevious(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
|
||||
// Setup listeners.
|
||||
var first_got, second_got string
|
||||
first := func(s string) *bool {
|
||||
first_got = s
|
||||
return nil
|
||||
}
|
||||
second := func(s string) *bool {
|
||||
second_got = s
|
||||
return nil
|
||||
}
|
||||
|
||||
broker.AddListener("dup", first)
|
||||
broker.AddListener("dup", second)
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
if first_got != "" {
|
||||
t.Errorf("first got %q, want empty string", first_got)
|
||||
}
|
||||
if second_got != want {
|
||||
t.Errorf("second got %q, want %q", second_got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRemovingListenerSuccessful(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
|
||||
// Setup listeners.
|
||||
var first_got, second_got string
|
||||
first := func(s string) *bool {
|
||||
first_got = s
|
||||
return nil
|
||||
}
|
||||
second := func(s string) *bool {
|
||||
second_got = s
|
||||
return nil
|
||||
}
|
||||
|
||||
broker.AddListener("1", first)
|
||||
broker.AddListener("2", second)
|
||||
broker.RemoveListener("1")
|
||||
|
||||
want := "hi"
|
||||
broker.Emit(&want)
|
||||
if first_got != "" {
|
||||
t.Errorf("first got %q, want empty string", first_got)
|
||||
}
|
||||
if second_got != want {
|
||||
t.Errorf("second got %q, want %q", second_got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerRemovingMissingListener(t *testing.T) {
|
||||
broker := &extension.EventBroker[string, bool]{}
|
||||
broker.RemoveListener("doesn't crash")
|
||||
}
|
||||
24
pkg/extension/event/events.go
Normal file
24
pkg/extension/event/events.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AddressParts contains the local and domain parts of an email address.
|
||||
type AddressParts struct {
|
||||
Local string
|
||||
Domain string
|
||||
}
|
||||
|
||||
// MessageMetadata contains the basic header data for a message event.
|
||||
type MessageMetadata struct {
|
||||
Mailbox string
|
||||
ID string
|
||||
From *mail.Address
|
||||
To []*mail.Address
|
||||
Date time.Time
|
||||
Subject string
|
||||
Size int64
|
||||
Seen bool
|
||||
}
|
||||
34
pkg/extension/host.go
Normal file
34
pkg/extension/host.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
)
|
||||
|
||||
// Host defines extension points for Inbucket.
|
||||
type Host struct {
|
||||
Events *Events
|
||||
}
|
||||
|
||||
// Events defines all the event types supported by the extension host.
|
||||
//
|
||||
// Before-events provide an opportunity for extensions to alter how Inbucket responds to that type
|
||||
// of event. These events are processed synchronously; expensive operations will reduce the
|
||||
// perceived performance of Inbucket. The first listener in the list to respond with a non-nil
|
||||
// value will determine the response, and the remaining listeners will not be called.
|
||||
//
|
||||
// After-events allow extensions to take an action after an event has completed. These events are
|
||||
// processed asynchronously with respect to the rest of Inbuckets operation. However, an event
|
||||
// listener will not be called until the one before it complets.
|
||||
type Events struct {
|
||||
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
|
||||
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
|
||||
BeforeMailAccepted EventBroker[event.AddressParts, bool]
|
||||
}
|
||||
|
||||
// Void indicates the event emitter will ignore any value returned by listeners.
|
||||
type Void struct{}
|
||||
|
||||
// NewHost creates a new extension host.
|
||||
func NewHost() *Host {
|
||||
return &Host{Events: &Events{}}
|
||||
}
|
||||
92
pkg/extension/luahost/bind_address.go
Normal file
92
pkg/extension/luahost/bind_address.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const mailAddressName = "address"
|
||||
|
||||
func registerMailAddressType(ls *lua.LState) {
|
||||
mt := ls.NewTypeMetatable(mailAddressName)
|
||||
ls.SetGlobal(mailAddressName, mt)
|
||||
|
||||
// Static attributes.
|
||||
ls.SetField(mt, "new", ls.NewFunction(newMailAddress))
|
||||
|
||||
// Methods.
|
||||
ls.SetField(mt, "__index", ls.NewFunction(mailAddressIndex))
|
||||
ls.SetField(mt, "__newindex", ls.NewFunction(mailAddressNewIndex))
|
||||
}
|
||||
|
||||
func newMailAddress(ls *lua.LState) int {
|
||||
val := &mail.Address{
|
||||
Name: ls.CheckString(1),
|
||||
Address: ls.CheckString(2),
|
||||
}
|
||||
ud := wrapMailAddress(ls, val)
|
||||
ls.Push(ud)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
func wrapMailAddress(ls *lua.LState, val *mail.Address) *lua.LUserData {
|
||||
ud := ls.NewUserData()
|
||||
ud.Value = val
|
||||
ls.SetMetatable(ud, ls.GetTypeMetatable(mailAddressName))
|
||||
|
||||
return ud
|
||||
}
|
||||
|
||||
func unwrapMailAddress(ud *lua.LUserData) (*mail.Address, bool) {
|
||||
val, ok := ud.Value.(*mail.Address)
|
||||
return val, ok
|
||||
}
|
||||
|
||||
func checkMailAddress(ls *lua.LState, pos int) *mail.Address {
|
||||
ud := ls.CheckUserData(pos)
|
||||
if val, ok := ud.Value.(*mail.Address); ok {
|
||||
return val
|
||||
}
|
||||
ls.ArgError(1, mailAddressName+" expected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets a field value from MailAddress user object. This emulates a Lua table,
|
||||
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
|
||||
func mailAddressIndex(ls *lua.LState) int {
|
||||
a := checkMailAddress(ls, 1)
|
||||
field := ls.CheckString(2)
|
||||
|
||||
// Push the requested field's value onto the stack.
|
||||
switch field {
|
||||
case "name":
|
||||
ls.Push(lua.LString(a.Name))
|
||||
case "address":
|
||||
ls.Push(lua.LString(a.Address))
|
||||
default:
|
||||
// Unknown field.
|
||||
ls.Push(lua.LNil)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// Sets a field value on MailAddress user object. This emulates a Lua table,
|
||||
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
|
||||
func mailAddressNewIndex(ls *lua.LState) int {
|
||||
a := checkMailAddress(ls, 1)
|
||||
index := ls.CheckString(2)
|
||||
|
||||
switch index {
|
||||
case "name":
|
||||
a.Name = ls.CheckString(3)
|
||||
case "address":
|
||||
a.Address = ls.CheckString(3)
|
||||
default:
|
||||
ls.RaiseError("invalid index %q", index)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
54
pkg/extension/luahost/bind_address_test.go
Normal file
54
pkg/extension/luahost/bind_address_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestMailAddressGetters(t *testing.T) {
|
||||
want := &mail.Address{
|
||||
Name: "Roberto I",
|
||||
Address: "ri@example.com",
|
||||
}
|
||||
script := `
|
||||
assert(addr, "addr should not be nil")
|
||||
|
||||
want = "Roberto I"
|
||||
got = addr.name
|
||||
assert(got == want, string.format("got name %q, want %q", got, want))
|
||||
|
||||
want = "ri@example.com"
|
||||
got = addr.address
|
||||
assert(got == want, string.format("got address %q, want %q", got, want))
|
||||
`
|
||||
|
||||
ls := lua.NewState()
|
||||
registerMailAddressType(ls)
|
||||
ls.SetGlobal("addr", wrapMailAddress(ls, want))
|
||||
require.NoError(t, ls.DoString(script))
|
||||
}
|
||||
|
||||
func TestMailAddressSetters(t *testing.T) {
|
||||
want := &mail.Address{
|
||||
Name: "Roberto I",
|
||||
Address: "ri@example.com",
|
||||
}
|
||||
script := `
|
||||
assert(addr, "addr should not be nil")
|
||||
|
||||
addr.name = "Roberto I"
|
||||
addr.address = "ri@example.com"
|
||||
`
|
||||
|
||||
got := &mail.Address{}
|
||||
ls := lua.NewState()
|
||||
registerMailAddressType(ls)
|
||||
ls.SetGlobal("addr", wrapMailAddress(ls, got))
|
||||
require.NoError(t, ls.DoString(script))
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
218
pkg/extension/luahost/bind_inbucket.go
Normal file
218
pkg/extension/luahost/bind_inbucket.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const (
|
||||
inbucketName = "inbucket"
|
||||
inbucketBeforeName = "inbucket_before"
|
||||
inbucketAfterName = "inbucket_after"
|
||||
)
|
||||
|
||||
// Inbucket is the primary Lua interface data structure.
|
||||
type Inbucket struct {
|
||||
After InbucketAfterFuncs
|
||||
Before InbucketBeforeFuncs
|
||||
}
|
||||
|
||||
// InbucketAfterFuncs holds references to Lua extension functions to be called async
|
||||
// after Inbucket handles an event.
|
||||
type InbucketAfterFuncs struct {
|
||||
MessageDeleted *lua.LFunction
|
||||
MessageStored *lua.LFunction
|
||||
}
|
||||
|
||||
// InbucketBeforeFuncs holds references to Lua extension functions to be called
|
||||
// before Inbucket handles an event.
|
||||
type InbucketBeforeFuncs struct {
|
||||
MailAccepted *lua.LFunction
|
||||
}
|
||||
|
||||
func registerInbucketTypes(ls *lua.LState) {
|
||||
// inbucket type.
|
||||
mt := ls.NewTypeMetatable(inbucketName)
|
||||
ls.SetField(mt, "__index", ls.NewFunction(inbucketIndex))
|
||||
|
||||
// inbucket global var.
|
||||
ud := wrapInbucket(ls, &Inbucket{})
|
||||
ls.SetGlobal(inbucketName, ud)
|
||||
|
||||
// inbucket.after type.
|
||||
mt = ls.NewTypeMetatable(inbucketAfterName)
|
||||
ls.SetField(mt, "__index", ls.NewFunction(inbucketAfterIndex))
|
||||
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketAfterNewIndex))
|
||||
|
||||
// inbucket.before type.
|
||||
mt = ls.NewTypeMetatable(inbucketBeforeName)
|
||||
ls.SetField(mt, "__index", ls.NewFunction(inbucketBeforeIndex))
|
||||
ls.SetField(mt, "__newindex", ls.NewFunction(inbucketBeforeNewIndex))
|
||||
}
|
||||
|
||||
func wrapInbucket(ls *lua.LState, val *Inbucket) *lua.LUserData {
|
||||
ud := ls.NewUserData()
|
||||
ud.Value = val
|
||||
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketName))
|
||||
|
||||
return ud
|
||||
}
|
||||
|
||||
func wrapInbucketAfter(ls *lua.LState, val *InbucketAfterFuncs) *lua.LUserData {
|
||||
ud := ls.NewUserData()
|
||||
ud.Value = val
|
||||
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketAfterName))
|
||||
|
||||
return ud
|
||||
}
|
||||
|
||||
func wrapInbucketBefore(ls *lua.LState, val *InbucketBeforeFuncs) *lua.LUserData {
|
||||
ud := ls.NewUserData()
|
||||
ud.Value = val
|
||||
ls.SetMetatable(ud, ls.GetTypeMetatable(inbucketBeforeName))
|
||||
|
||||
return ud
|
||||
}
|
||||
|
||||
func getInbucket(ls *lua.LState) (*Inbucket, error) {
|
||||
lv := ls.GetGlobal(inbucketName)
|
||||
if lv == nil {
|
||||
return nil, errors.New("inbucket object was nil")
|
||||
}
|
||||
|
||||
ud, ok := lv.(*lua.LUserData)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("inbucket object was type %s instead of UserData", lv.Type())
|
||||
}
|
||||
|
||||
val, ok := ud.Value.(*Inbucket)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("inbucket object (%v) could not be cast", ud.Value)
|
||||
}
|
||||
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func checkInbucket(ls *lua.LState, pos int) *Inbucket {
|
||||
ud := ls.CheckUserData(pos)
|
||||
if val, ok := ud.Value.(*Inbucket); ok {
|
||||
return val
|
||||
}
|
||||
ls.ArgError(1, inbucketName+" expected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkInbucketAfter(ls *lua.LState, pos int) *InbucketAfterFuncs {
|
||||
ud := ls.CheckUserData(pos)
|
||||
if val, ok := ud.Value.(*InbucketAfterFuncs); ok {
|
||||
return val
|
||||
}
|
||||
ls.ArgError(1, inbucketAfterName+" expected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkInbucketBefore(ls *lua.LState, pos int) *InbucketBeforeFuncs {
|
||||
ud := ls.CheckUserData(pos)
|
||||
if val, ok := ud.Value.(*InbucketBeforeFuncs); ok {
|
||||
return val
|
||||
}
|
||||
ls.ArgError(1, inbucketBeforeName+" expected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// inbucket getter.
|
||||
func inbucketIndex(ls *lua.LState) int {
|
||||
ib := checkInbucket(ls, 1)
|
||||
field := ls.CheckString(2)
|
||||
|
||||
// Push the requested field's value onto the stack.
|
||||
switch field {
|
||||
case "after":
|
||||
ls.Push(wrapInbucketAfter(ls, &ib.After))
|
||||
case "before":
|
||||
ls.Push(wrapInbucketBefore(ls, &ib.Before))
|
||||
default:
|
||||
// Unknown field.
|
||||
ls.Push(lua.LNil)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// inbucket.after getter.
|
||||
func inbucketAfterIndex(ls *lua.LState) int {
|
||||
after := checkInbucketAfter(ls, 1)
|
||||
field := ls.CheckString(2)
|
||||
|
||||
// Push the requested field's value onto the stack.
|
||||
switch field {
|
||||
case "message_deleted":
|
||||
ls.Push(funcOrNil(after.MessageDeleted))
|
||||
case "message_stored":
|
||||
ls.Push(funcOrNil(after.MessageStored))
|
||||
default:
|
||||
// Unknown field.
|
||||
ls.Push(lua.LNil)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// inbucket.after setter.
|
||||
func inbucketAfterNewIndex(ls *lua.LState) int {
|
||||
m := checkInbucketAfter(ls, 1)
|
||||
index := ls.CheckString(2)
|
||||
|
||||
switch index {
|
||||
case "message_deleted":
|
||||
m.MessageDeleted = ls.CheckFunction(3)
|
||||
case "message_stored":
|
||||
m.MessageStored = ls.CheckFunction(3)
|
||||
default:
|
||||
ls.RaiseError("invalid inbucket.after index %q", index)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// inbucket.before getter.
|
||||
func inbucketBeforeIndex(ls *lua.LState) int {
|
||||
before := checkInbucketBefore(ls, 1)
|
||||
field := ls.CheckString(2)
|
||||
|
||||
// Push the requested field's value onto the stack.
|
||||
switch field {
|
||||
case "mail_accepted":
|
||||
ls.Push(funcOrNil(before.MailAccepted))
|
||||
default:
|
||||
// Unknown field.
|
||||
ls.Push(lua.LNil)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// inbucket.before setter.
|
||||
func inbucketBeforeNewIndex(ls *lua.LState) int {
|
||||
m := checkInbucketBefore(ls, 1)
|
||||
index := ls.CheckString(2)
|
||||
|
||||
switch index {
|
||||
case "mail_accepted":
|
||||
m.MailAccepted = ls.CheckFunction(3)
|
||||
default:
|
||||
ls.RaiseError("invalid inbucket.before index %q", index)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func funcOrNil(f *lua.LFunction) lua.LValue {
|
||||
if f == nil {
|
||||
return lua.LNil
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
102
pkg/extension/luahost/bind_inbucket_test.go
Normal file
102
pkg/extension/luahost/bind_inbucket_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestInbucketAfterFuncs(t *testing.T) {
|
||||
// This Script registers each function and calls it. No effort is made to use the arguments
|
||||
// that Inbucket expects, this is only to validate the inbucket.after data structure getters
|
||||
// and setters.
|
||||
script := `
|
||||
assert(inbucket, "inbucket should not be nil")
|
||||
assert(inbucket.after, "inbucket.after should not be nil")
|
||||
|
||||
local fns = { "message_deleted", "message_stored" }
|
||||
|
||||
-- Verify functions start off nil.
|
||||
for i, name in ipairs(fns) do
|
||||
assert(inbucket.after[name] == nil, "after." .. name .. " should be nil")
|
||||
end
|
||||
|
||||
-- Test function to track func calls made, ensures no crossed wires.
|
||||
local calls = {}
|
||||
function makeTestFunc(create_name)
|
||||
return function(call_name)
|
||||
calls[create_name] = call_name
|
||||
end
|
||||
end
|
||||
|
||||
-- Set after functions, verify not nil, and call them.
|
||||
for i, name in ipairs(fns) do
|
||||
inbucket.after[name] = makeTestFunc(name)
|
||||
assert(inbucket.after[name], "after." .. name .. " should not be nil")
|
||||
end
|
||||
|
||||
-- Call each function. Separate loop to verify final state in 'calls'.
|
||||
for i, name in ipairs(fns) do
|
||||
inbucket.after[name](name)
|
||||
end
|
||||
|
||||
-- Verify functions were called.
|
||||
for i, name in ipairs(fns) do
|
||||
assert(calls[name], "after." .. name .. " should have been called")
|
||||
assert(calls[name] == name,
|
||||
string.format("after.%s was called with incorrect argument %s", name, calls[name]))
|
||||
end
|
||||
`
|
||||
|
||||
ls := lua.NewState()
|
||||
registerInbucketTypes(ls)
|
||||
require.NoError(t, ls.DoString(script))
|
||||
}
|
||||
|
||||
func TestInbucketBeforeFuncs(t *testing.T) {
|
||||
// This Script registers each function and calls it. No effort is made to use the arguments
|
||||
// that Inbucket expects, this is only to validate the inbucket.before data structure getters
|
||||
// and setters.
|
||||
script := `
|
||||
assert(inbucket, "inbucket should not be nil")
|
||||
assert(inbucket.before, "inbucket.before should not be nil")
|
||||
|
||||
local fns = { "mail_accepted" }
|
||||
|
||||
-- Verify functions start off nil.
|
||||
for i, name in ipairs(fns) do
|
||||
assert(inbucket.before[name] == nil, "before." .. name .. " should be nil")
|
||||
end
|
||||
|
||||
-- Test function to track func calls made, ensures no crossed wires.
|
||||
local calls = {}
|
||||
function makeTestFunc(create_name)
|
||||
return function(call_name)
|
||||
calls[create_name] = call_name
|
||||
end
|
||||
end
|
||||
|
||||
-- Set before functions, verify not nil, and call them.
|
||||
for i, name in ipairs(fns) do
|
||||
inbucket.before[name] = makeTestFunc(name)
|
||||
assert(inbucket.before[name], "before." .. name .. " should not be nil")
|
||||
end
|
||||
|
||||
-- Call each function. Separate loop to verify final state in 'calls'.
|
||||
for i, name in ipairs(fns) do
|
||||
inbucket.before[name](name)
|
||||
end
|
||||
|
||||
-- Verify functions were called.
|
||||
for i, name in ipairs(fns) do
|
||||
assert(calls[name], "before." .. name .. " should have been called")
|
||||
assert(calls[name] == name,
|
||||
string.format("before.%s was called with incorrect argument %s", name, calls[name]))
|
||||
end
|
||||
`
|
||||
|
||||
ls := lua.NewState()
|
||||
registerInbucketTypes(ls)
|
||||
require.NoError(t, ls.DoString(script))
|
||||
}
|
||||
119
pkg/extension/luahost/bind_message.go
Normal file
119
pkg/extension/luahost/bind_message.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const messageMetadataName = "message_metadata"
|
||||
|
||||
func registerMessageMetadataType(ls *lua.LState) {
|
||||
mt := ls.NewTypeMetatable(messageMetadataName)
|
||||
ls.SetGlobal(messageMetadataName, mt)
|
||||
|
||||
// Static attributes.
|
||||
ls.SetField(mt, "new", ls.NewFunction(newMessageMetadata))
|
||||
|
||||
// Methods.
|
||||
ls.SetField(mt, "__index", ls.NewFunction(messageMetadataIndex))
|
||||
ls.SetField(mt, "__newindex", ls.NewFunction(messageMetadataNewIndex))
|
||||
}
|
||||
|
||||
func newMessageMetadata(ls *lua.LState) int {
|
||||
val := &event.MessageMetadata{}
|
||||
ud := wrapMessageMetadata(ls, val)
|
||||
ls.Push(ud)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
func wrapMessageMetadata(ls *lua.LState, val *event.MessageMetadata) *lua.LUserData {
|
||||
ud := ls.NewUserData()
|
||||
ud.Value = val
|
||||
ls.SetMetatable(ud, ls.GetTypeMetatable(messageMetadataName))
|
||||
|
||||
return ud
|
||||
}
|
||||
|
||||
func checkMessageMetadata(ls *lua.LState, pos int) *event.MessageMetadata {
|
||||
ud := ls.CheckUserData(pos)
|
||||
if v, ok := ud.Value.(*event.MessageMetadata); ok {
|
||||
return v
|
||||
}
|
||||
ls.ArgError(1, messageMetadataName+" expected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets a field value from MessageMetadata user object. This emulates a Lua table,
|
||||
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
|
||||
func messageMetadataIndex(ls *lua.LState) int {
|
||||
m := checkMessageMetadata(ls, 1)
|
||||
field := ls.CheckString(2)
|
||||
|
||||
// Push the requested field's value onto the stack.
|
||||
switch field {
|
||||
case "mailbox":
|
||||
ls.Push(lua.LString(m.Mailbox))
|
||||
case "id":
|
||||
ls.Push(lua.LString(m.ID))
|
||||
case "from":
|
||||
ls.Push(wrapMailAddress(ls, m.From))
|
||||
case "to":
|
||||
lt := &lua.LTable{}
|
||||
for _, v := range m.To {
|
||||
lt.Append(wrapMailAddress(ls, v))
|
||||
}
|
||||
ls.Push(lt)
|
||||
case "date":
|
||||
ls.Push(lua.LNumber(m.Date.Unix()))
|
||||
case "subject":
|
||||
ls.Push(lua.LString(m.Subject))
|
||||
case "size":
|
||||
ls.Push(lua.LNumber(m.Size))
|
||||
default:
|
||||
// Unknown field.
|
||||
ls.Push(lua.LNil)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// Sets a field value on MessageMetadata user object. This emulates a Lua table,
|
||||
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
|
||||
func messageMetadataNewIndex(ls *lua.LState) int {
|
||||
m := checkMessageMetadata(ls, 1)
|
||||
index := ls.CheckString(2)
|
||||
|
||||
switch index {
|
||||
case "mailbox":
|
||||
m.Mailbox = ls.CheckString(3)
|
||||
case "id":
|
||||
m.ID = ls.CheckString(3)
|
||||
case "from":
|
||||
m.From = checkMailAddress(ls, 3)
|
||||
case "to":
|
||||
lt := ls.CheckTable(3)
|
||||
to := make([]*mail.Address, 0, 16)
|
||||
lt.ForEach(func(k, lv lua.LValue) {
|
||||
if ud, ok := lv.(*lua.LUserData); ok {
|
||||
if entry, ok := unwrapMailAddress(ud); ok {
|
||||
to = append(to, entry)
|
||||
}
|
||||
}
|
||||
})
|
||||
m.To = to
|
||||
case "date":
|
||||
m.Date = time.Unix(ls.CheckInt64(3), 0)
|
||||
case "subject":
|
||||
m.Subject = ls.CheckString(3)
|
||||
case "size":
|
||||
m.Size = ls.CheckInt64(3)
|
||||
default:
|
||||
ls.RaiseError("invalid index %q", index)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
91
pkg/extension/luahost/bind_message_test.go
Normal file
91
pkg/extension/luahost/bind_message_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestMessageMetadataGetters(t *testing.T) {
|
||||
want := &event.MessageMetadata{
|
||||
Mailbox: "mb1",
|
||||
ID: "id1",
|
||||
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||
Subject: "subj1",
|
||||
Size: 42,
|
||||
}
|
||||
script := `
|
||||
assert(msg, "msg should not be nil")
|
||||
|
||||
function assert_eq(got, want)
|
||||
assert(got == want, string.format("got name %q, wanted %q", got, want))
|
||||
end
|
||||
|
||||
assert_eq(msg.mailbox, "mb1")
|
||||
assert_eq(msg.id, "id1")
|
||||
assert_eq(msg.subject, "subj1")
|
||||
assert_eq(msg.size, 42)
|
||||
|
||||
assert_eq(msg.from.name, "name1")
|
||||
assert_eq(msg.from.address, "addr1")
|
||||
|
||||
assert_eq(table.getn(msg.to), 1)
|
||||
assert_eq(msg.to[1].name, "name2")
|
||||
assert_eq(msg.to[1].address, "addr2")
|
||||
|
||||
assert_eq(msg.date, 981173106)
|
||||
`
|
||||
|
||||
ls := lua.NewState()
|
||||
registerMessageMetadataType(ls)
|
||||
registerMailAddressType(ls)
|
||||
ls.SetGlobal("msg", wrapMessageMetadata(ls, want))
|
||||
require.NoError(t, ls.DoString(script))
|
||||
}
|
||||
|
||||
func TestMessageMetadataSetters(t *testing.T) {
|
||||
want := &event.MessageMetadata{
|
||||
Mailbox: "mb1",
|
||||
ID: "id1",
|
||||
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||
Subject: "subj1",
|
||||
Size: 42,
|
||||
}
|
||||
script := `
|
||||
assert(msg, "msg should not be nil")
|
||||
|
||||
msg.mailbox = "mb1"
|
||||
msg.id = "id1"
|
||||
msg.subject = "subj1"
|
||||
msg.size = 42
|
||||
|
||||
msg.from = address.new("name1", "addr1")
|
||||
msg.to = { address.new("name2", "addr2") }
|
||||
|
||||
msg.date = 981173106
|
||||
`
|
||||
|
||||
got := &event.MessageMetadata{}
|
||||
ls := lua.NewState()
|
||||
registerMessageMetadataType(ls)
|
||||
registerMailAddressType(ls)
|
||||
ls.SetGlobal("msg", wrapMessageMetadata(ls, got))
|
||||
require.NoError(t, ls.DoString(script))
|
||||
|
||||
// Timezones will cause a naive comparison to fail.
|
||||
assert.Equal(t, want.Date.Unix(), got.Date.Unix())
|
||||
now := time.Now()
|
||||
want.Date = now
|
||||
got.Date = now
|
||||
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
17
pkg/extension/luahost/bind_policy.go
Normal file
17
pkg/extension/luahost/bind_policy.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const policyName = "policy"
|
||||
|
||||
func registerPolicyType(ls *lua.LState) {
|
||||
mt := ls.NewTypeMetatable(policyName)
|
||||
ls.SetGlobal(policyName, mt)
|
||||
|
||||
// Static attributes.
|
||||
ls.SetField(mt, "allow", lua.LTrue)
|
||||
ls.SetField(mt, "deny", lua.LFalse)
|
||||
ls.SetField(mt, "defer", lua.LNil)
|
||||
}
|
||||
195
pkg/extension/luahost/lua.go
Normal file
195
pkg/extension/luahost/lua.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/config"
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"github.com/yuin/gopher-lua/parse"
|
||||
)
|
||||
|
||||
// Host of Lua extensions.
|
||||
type Host struct {
|
||||
extHost *extension.Host
|
||||
pool *statePool
|
||||
logContext zerolog.Context
|
||||
}
|
||||
|
||||
// New constructs a new Lua Host, pre-compiling the source.
|
||||
func New(conf config.Lua, extHost *extension.Host) (*Host, error) {
|
||||
scriptPath := conf.Path
|
||||
if scriptPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
logContext := log.With().Str("module", "lua")
|
||||
logger := logContext.Str("phase", "startup").Str("path", scriptPath).Logger()
|
||||
|
||||
// Pre-load, parse, and compile script.
|
||||
if fi, err := os.Stat(scriptPath); err != nil {
|
||||
logger.Info().Msg("Script file not found")
|
||||
return nil, nil
|
||||
} else if fi.IsDir() {
|
||||
return nil, fmt.Errorf("Lua script %v is a directory", scriptPath)
|
||||
}
|
||||
|
||||
logger.Info().Msg("Loading script")
|
||||
file, err := os.Open(scriptPath)
|
||||
defer file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewFromReader(extHost, bufio.NewReader(file), scriptPath)
|
||||
}
|
||||
|
||||
// NewFromReader constructs a new Lua Host, loading Lua source from the provided reader.
|
||||
// The provided path is used in logging and error messages.
|
||||
func NewFromReader(extHost *extension.Host, r io.Reader, path string) (*Host, error) {
|
||||
logContext := log.With().Str("module", "lua")
|
||||
logger := logContext.Str("phase", "startup").Str("path", path).Logger()
|
||||
|
||||
// Pre-parse, and compile script.
|
||||
chunk, err := parse.Parse(r, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proto, err := lua.Compile(chunk, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build the pool and confirm LState is retrievable.
|
||||
pool := newStatePool(proto)
|
||||
h := &Host{extHost: extHost, pool: pool, logContext: logContext}
|
||||
if ls, err := pool.getState(); err == nil {
|
||||
h.wireFunctions(logger, ls)
|
||||
|
||||
// State creation works, put it back.
|
||||
pool.putState(ls)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// CreateChannel creates a channel and places it into the named global variable
|
||||
// in newly created LStates.
|
||||
func (h *Host) CreateChannel(name string) chan lua.LValue {
|
||||
return h.pool.createChannel(name)
|
||||
}
|
||||
|
||||
// Detects global lua event listener functions and wires them up.
|
||||
func (h *Host) wireFunctions(logger zerolog.Logger, ls *lua.LState) {
|
||||
ib, err := getInbucket(ls)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to get inbucket global")
|
||||
}
|
||||
|
||||
events := h.extHost.Events
|
||||
const listenerName string = "lua"
|
||||
|
||||
if ib.After.MessageDeleted != nil {
|
||||
events.AfterMessageDeleted.AddListener(listenerName, h.handleAfterMessageDeleted)
|
||||
}
|
||||
if ib.After.MessageStored != nil {
|
||||
events.AfterMessageStored.AddListener(listenerName, h.handleAfterMessageStored)
|
||||
}
|
||||
if ib.Before.MailAccepted != nil {
|
||||
events.BeforeMailAccepted.AddListener(listenerName, h.handleBeforeMailAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Host) handleAfterMessageDeleted(msg event.MessageMetadata) {
|
||||
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_deleted")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer h.pool.putState(ls)
|
||||
|
||||
// Call lua function.
|
||||
logger.Debug().Msgf("Calling Lua function with %+v", msg)
|
||||
if err := ls.CallByParam(
|
||||
lua.P{Fn: ib.After.MessageDeleted, NRet: 0, Protect: true},
|
||||
wrapMessageMetadata(ls, &msg),
|
||||
); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Host) handleAfterMessageStored(msg event.MessageMetadata) {
|
||||
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer h.pool.putState(ls)
|
||||
|
||||
// Call lua function.
|
||||
logger.Debug().Msgf("Calling Lua function with %+v", msg)
|
||||
if err := ls.CallByParam(
|
||||
lua.P{Fn: ib.After.MessageStored, NRet: 0, Protect: true},
|
||||
wrapMessageMetadata(ls, &msg),
|
||||
); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Host) handleBeforeMailAccepted(addr event.AddressParts) *bool {
|
||||
logger, ls, ib, ok := h.prepareInbucketFuncCall("after.message_stored")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
defer h.pool.putState(ls)
|
||||
|
||||
logger.Debug().Msgf("Calling Lua function with %+v", addr)
|
||||
if err := ls.CallByParam(
|
||||
lua.P{Fn: ib.Before.MailAccepted, NRet: 1, Protect: true},
|
||||
lua.LString(addr.Local),
|
||||
lua.LString(addr.Domain),
|
||||
); err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||
return nil
|
||||
}
|
||||
|
||||
lval := ls.Get(1)
|
||||
ls.Pop(1)
|
||||
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
|
||||
|
||||
if lval.Type() == lua.LTNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := true
|
||||
if lua.LVIsFalse(lval) {
|
||||
result = false
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
// Common preparation for calling Lua functions.
|
||||
func (h *Host) prepareInbucketFuncCall(funcName string) (logger zerolog.Logger, ls *lua.LState, ib *Inbucket, ok bool) {
|
||||
logger = h.logContext.Str("event", funcName).Logger()
|
||||
|
||||
ls, err := h.pool.getState()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to get Lua state instance from pool")
|
||||
return logger, nil, nil, false
|
||||
}
|
||||
|
||||
ib, err = getInbucket(ls)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to obtain Lua inbucket object")
|
||||
return logger, nil, nil, false
|
||||
}
|
||||
|
||||
return logger, ls, ib, true
|
||||
}
|
||||
166
pkg/extension/luahost/lua_test.go
Normal file
166
pkg/extension/luahost/lua_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package luahost_test
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/extension/luahost"
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// LuaInit holds useful test globals.
|
||||
const LuaInit = `
|
||||
async = false
|
||||
test_ok = true
|
||||
|
||||
-- Sends marks tests failed instead of erroring when enabled.
|
||||
function assert_async(value, message)
|
||||
if not value then
|
||||
if async then
|
||||
print(message)
|
||||
test_ok = false
|
||||
else
|
||||
error(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Tests plain values and list-style tables.
|
||||
function assert_eq(got, want)
|
||||
if type(got) == "table" and type(want) == "table" then
|
||||
assert_async(#got == #want, string.format("got %d elements, wanted %d", #got, #want))
|
||||
|
||||
for i, gotv in ipairs(got) do
|
||||
local wantv = want[i]
|
||||
assert_eq(gotv, wantv, "got[%d] = %q, wanted %q", gotv, wantv)
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
assert_async(got == want, string.format("got %q, wanted %q", got, want))
|
||||
end
|
||||
|
||||
function assert_contains(got, want)
|
||||
assert_async(string.find(got, want),
|
||||
string.format("got %q, wanted it to contain %q", got, want))
|
||||
end
|
||||
`
|
||||
|
||||
func TestEmptyScript(t *testing.T) {
|
||||
script := ""
|
||||
extHost := extension.NewHost()
|
||||
|
||||
_, err := luahost.NewFromReader(extHost, strings.NewReader(script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAfterMessageDeleted(t *testing.T) {
|
||||
// Register lua event listener, setup notify channel.
|
||||
script := `
|
||||
async = true
|
||||
|
||||
function inbucket.after.message_deleted(msg)
|
||||
-- Full message bindings tested elsewhere.
|
||||
assert_eq(msg.mailbox, "mb1")
|
||||
assert_eq(msg.id, "id1")
|
||||
notify:send(test_ok)
|
||||
end
|
||||
`
|
||||
extHost := extension.NewHost()
|
||||
luaHost, err := luahost.NewFromReader(extHost, strings.NewReader(LuaInit+script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
notify := luaHost.CreateChannel("notify")
|
||||
|
||||
// Send event, check channel response is true.
|
||||
msg := &event.MessageMetadata{
|
||||
Mailbox: "mb1",
|
||||
ID: "id1",
|
||||
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||
Subject: "subj1",
|
||||
Size: 42,
|
||||
}
|
||||
extHost.Events.AfterMessageDeleted.Emit(msg)
|
||||
assertNotified(t, notify)
|
||||
}
|
||||
|
||||
func TestAfterMessageStored(t *testing.T) {
|
||||
// Register lua event listener, setup notify channel.
|
||||
script := `
|
||||
async = true
|
||||
|
||||
function inbucket.after.message_stored(msg)
|
||||
-- Full message bindings tested elsewhere.
|
||||
assert_eq(msg.mailbox, "mb1")
|
||||
assert_eq(msg.id, "id1")
|
||||
notify:send(test_ok)
|
||||
end
|
||||
`
|
||||
extHost := extension.NewHost()
|
||||
luaHost, err := luahost.NewFromReader(extHost, strings.NewReader(LuaInit+script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
notify := luaHost.CreateChannel("notify")
|
||||
|
||||
// Send event, check channel response is true.
|
||||
msg := &event.MessageMetadata{
|
||||
Mailbox: "mb1",
|
||||
ID: "id1",
|
||||
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||
To: []*mail.Address{{Name: "name2", Address: "addr2"}},
|
||||
Date: time.Date(2001, time.February, 3, 4, 5, 6, 0, time.UTC),
|
||||
Subject: "subj1",
|
||||
Size: 42,
|
||||
}
|
||||
extHost.Events.AfterMessageStored.Emit(msg)
|
||||
assertNotified(t, notify)
|
||||
}
|
||||
|
||||
func TestBeforeMailAccepted(t *testing.T) {
|
||||
// Register lua event listener.
|
||||
script := `
|
||||
function inbucket.before.mail_accepted(localpart, domain)
|
||||
return localpart == "from" and domain == "test"
|
||||
end
|
||||
`
|
||||
extHost := extension.NewHost()
|
||||
_, err := luahost.NewFromReader(extHost, strings.NewReader(script), "test.lua")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send event to be accepted.
|
||||
addr := &event.AddressParts{Local: "from", Domain: "test"}
|
||||
got := extHost.Events.BeforeMailAccepted.Emit(addr)
|
||||
want := true
|
||||
require.NotNil(t, got)
|
||||
if *got != want {
|
||||
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
|
||||
}
|
||||
|
||||
// Send event to be denied.
|
||||
addr = &event.AddressParts{Local: "reject", Domain: "me"}
|
||||
got = extHost.Events.BeforeMailAccepted.Emit(addr)
|
||||
want = false
|
||||
require.NotNil(t, got)
|
||||
if *got != want {
|
||||
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotified(t *testing.T, notify chan lua.LValue) {
|
||||
t.Helper()
|
||||
select {
|
||||
case reslv := <-notify:
|
||||
// Lua function received event.
|
||||
if lua.LVIsFalse(reslv) {
|
||||
t.Error("Lua responsed with false, wanted true")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Lua did not respond to event within timeout")
|
||||
}
|
||||
}
|
||||
104
pkg/extension/luahost/pool.go
Normal file
104
pkg/extension/luahost/pool.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/cjoudrey/gluahttp"
|
||||
"github.com/inbucket/gopher-json"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
type statePool struct {
|
||||
sync.Mutex
|
||||
funcProto *lua.FunctionProto // Compiled lua.
|
||||
states []*lua.LState // Pool of available LStates.
|
||||
channels map[string]chan lua.LValue // Global interop channels.
|
||||
}
|
||||
|
||||
func newStatePool(funcProto *lua.FunctionProto) *statePool {
|
||||
return &statePool{
|
||||
funcProto: funcProto,
|
||||
channels: make(map[string]chan lua.LValue),
|
||||
}
|
||||
}
|
||||
|
||||
// newState creates a new LState and configures it. Lock must be held.
|
||||
func (lp *statePool) newState() (*lua.LState, error) {
|
||||
ls := lua.NewState()
|
||||
|
||||
// Load supplemental native modules.
|
||||
ls.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader)
|
||||
ls.PreloadModule("json", json.Loader)
|
||||
|
||||
// Setup channels.
|
||||
for name, ch := range lp.channels {
|
||||
ls.SetGlobal(name, lua.LChannel(ch))
|
||||
}
|
||||
|
||||
// Register custom types.
|
||||
registerInbucketTypes(ls)
|
||||
registerMessageMetadataType(ls)
|
||||
registerMailAddressType(ls)
|
||||
registerPolicyType(ls)
|
||||
|
||||
// Run compiled script.
|
||||
ls.Push(ls.NewFunctionFromProto(lp.funcProto))
|
||||
if err := ls.PCall(0, lua.MultRet, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ls, nil
|
||||
}
|
||||
|
||||
// getState returns a free LState, or creates a new one.
|
||||
func (lp *statePool) getState() (*lua.LState, error) {
|
||||
lp.Lock()
|
||||
defer lp.Unlock()
|
||||
|
||||
ln := len(lp.states)
|
||||
if ln == 0 {
|
||||
return lp.newState()
|
||||
}
|
||||
|
||||
state := lp.states[ln-1]
|
||||
lp.states = lp.states[0 : ln-1]
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// putState returns the LState to the pool.
|
||||
func (lp *statePool) putState(state *lua.LState) {
|
||||
if state.IsClosed() {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear stack.
|
||||
state.Pop(state.GetTop())
|
||||
|
||||
lp.Lock()
|
||||
defer lp.Unlock()
|
||||
|
||||
lp.states = append(lp.states, state)
|
||||
}
|
||||
|
||||
// createChannel creates a new channel, which will become a global variable in
|
||||
// newly created LStates. We also destroy any pooled states.
|
||||
//
|
||||
// Warning: There may still be checked out LStates that will not have the value
|
||||
// set, which could be put back into the pool.
|
||||
func (lp *statePool) createChannel(name string) chan lua.LValue {
|
||||
lp.Lock()
|
||||
defer lp.Unlock()
|
||||
|
||||
ch := make(chan lua.LValue, 10)
|
||||
lp.channels[name] = ch
|
||||
|
||||
// Flush state pool.
|
||||
for _, s := range lp.states {
|
||||
s.Close()
|
||||
}
|
||||
lp.states = lp.states[:0]
|
||||
|
||||
return ch
|
||||
}
|
||||
101
pkg/extension/luahost/pool_test.go
Normal file
101
pkg/extension/luahost/pool_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package luahost
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"github.com/yuin/gopher-lua/parse"
|
||||
)
|
||||
|
||||
func makeEmptyPool() *statePool {
|
||||
source := strings.NewReader("-- Empty source")
|
||||
|
||||
chunk, err := parse.Parse(source, "from string")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
proto, err := lua.Compile(chunk, "from string")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return newStatePool(proto)
|
||||
}
|
||||
|
||||
func TestPoolGetsDistinct(t *testing.T) {
|
||||
pool := makeEmptyPool()
|
||||
|
||||
a, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
b, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
|
||||
if a == b {
|
||||
t.Error("Got pool a == b, expected distinct pools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolGrowsWithPuts(t *testing.T) {
|
||||
pool := makeEmptyPool()
|
||||
|
||||
a, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
b, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
|
||||
|
||||
pool.putState(a)
|
||||
pool.putState(b)
|
||||
|
||||
want := 2
|
||||
if got := len(pool.states); got != want {
|
||||
t.Errorf("len pool.states got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Closed LStates should not be added to the pool.
|
||||
func TestPoolPutDiscardsClosed(t *testing.T) {
|
||||
pool := makeEmptyPool()
|
||||
|
||||
a, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
|
||||
|
||||
a.Close()
|
||||
pool.putState(a)
|
||||
assert.Equal(t, 0, len(pool.states), "Wanted pool to remain empty")
|
||||
}
|
||||
|
||||
func TestPoolPutClearsStack(t *testing.T) {
|
||||
pool := makeEmptyPool()
|
||||
|
||||
ls, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(pool.states), "Wanted pool to be empty")
|
||||
|
||||
// Setup stack.
|
||||
ls.Push(lua.LNumber(4))
|
||||
ls.Push(lua.LString("bacon"))
|
||||
require.Equal(t, 2, ls.GetTop(), "Want stack to have two items")
|
||||
|
||||
// Return and verify stack cleared.
|
||||
pool.putState(ls)
|
||||
assert.Equal(t, 1, len(pool.states), "Wanted pool to have one item")
|
||||
require.Equal(t, 0, ls.GetTop(), "Want stack to be empty")
|
||||
}
|
||||
|
||||
func TestPoolSetsChannels(t *testing.T) {
|
||||
pool := makeEmptyPool()
|
||||
pool.createChannel("test_chan")
|
||||
|
||||
s, err := pool.getState()
|
||||
require.NoError(t, err)
|
||||
|
||||
got := s.GetGlobal("test_chan")
|
||||
assert.Equal(t, lua.LTChannel, got.Type(),
|
||||
"Got global type %v, wanted LTChannel", got.Type().String())
|
||||
}
|
||||
168
pkg/message/manager.go
Normal file
168
pkg/message/manager.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"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) ([]*event.MessageMetadata, 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
|
||||
ExtHost *extension.Host
|
||||
}
|
||||
|
||||
// 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: event.MessageMetadata{
|
||||
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
|
||||
}
|
||||
|
||||
// Emit message stored event.
|
||||
event := delivery.Meta
|
||||
event.ID = id
|
||||
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns a slice of metadata for the specified mailbox.
|
||||
func (s *StoreManager) GetMetadata(mailbox string) ([]*event.MessageMetadata, error) {
|
||||
messages, err := s.Store.GetMessages(mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metas := make([]*event.MessageMetadata, 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{MessageMetadata: *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) *event.MessageMetadata {
|
||||
return &event.MessageMetadata{
|
||||
Mailbox: m.Mailbox(),
|
||||
ID: m.ID(),
|
||||
From: m.From(),
|
||||
To: m.To(),
|
||||
Date: m.Date(),
|
||||
Subject: m.Subject(),
|
||||
Size: m.Size(),
|
||||
Seen: m.Seen(),
|
||||
}
|
||||
}
|
||||
38
pkg/message/manager_test.go
Normal file
38
pkg/message/manager_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package message_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/message"
|
||||
"github.com/inbucket/inbucket/pkg/policy"
|
||||
"github.com/inbucket/inbucket/pkg/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestManagerEmitsMessageStoredEvent(t *testing.T) {
|
||||
extHost := extension.NewHost()
|
||||
sm := &message.StoreManager{
|
||||
AddrPolicy: &policy.Addressing{},
|
||||
Store: test.NewStore(),
|
||||
ExtHost: extHost,
|
||||
}
|
||||
|
||||
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
||||
|
||||
// Attempt to deliver a message to generate event.
|
||||
if _, err := sm.Deliver(
|
||||
&policy.Recipient{},
|
||||
"from@example.com",
|
||||
[]*policy.Recipient{},
|
||||
"prefix",
|
||||
[]byte("From: from@example.com\n\ntest email"),
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := listener()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, got, "No event received, or it was nil")
|
||||
}
|
||||
106
pkg/message/message.go
Normal file
106
pkg/message/message.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package message contains message handling logic.
|
||||
package message
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/inbucket/inbucket/pkg/storage"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// Message holds both the metadata and content of a message.
|
||||
type Message struct {
|
||||
event.MessageMetadata
|
||||
env *enmime.Envelope
|
||||
}
|
||||
|
||||
// New constructs a new Message
|
||||
func New(m event.MessageMetadata, e *enmime.Envelope) *Message {
|
||||
return &Message{
|
||||
MessageMetadata: 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 event.MessageMetadata
|
||||
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, ",")
|
||||
}
|
||||
161
pkg/msghub/hub.go
Normal file
161
pkg/msghub/hub.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package msghub
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"context"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Length of msghub operation queue
|
||||
const opChanLen = 100
|
||||
|
||||
// Listener receives the contents of the history buffer, followed by new messages
|
||||
type Listener interface {
|
||||
Receive(msg event.MessageMetadata) error
|
||||
Delete(mailbox string, id string) 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(historyLen int, extHost *extension.Host) *Hub {
|
||||
hub := &Hub{
|
||||
history: ring.New(historyLen),
|
||||
listeners: make(map[Listener]struct{}),
|
||||
opChan: make(chan func(h *Hub), opChanLen),
|
||||
}
|
||||
|
||||
// Register an extension event listener for MessageStored.
|
||||
extHost.Events.AfterMessageStored.AddListener("msghub",
|
||||
func(msg event.MessageMetadata) {
|
||||
hub.Dispatch(msg)
|
||||
})
|
||||
|
||||
extHost.Events.AfterMessageDeleted.AddListener("msghub",
|
||||
func(msg event.MessageMetadata) {
|
||||
hub.Delete(msg.Mailbox, msg.ID)
|
||||
})
|
||||
|
||||
return hub
|
||||
}
|
||||
|
||||
// Start Hub processing loop.
|
||||
func (hub *Hub) Start(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Shutdown
|
||||
close(hub.opChan)
|
||||
return
|
||||
case op := <-hub.opChan:
|
||||
hub.runOp(op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 event.MessageMetadata) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
if h.history != nil {
|
||||
// Add to history buffer
|
||||
h.history.Value = msg
|
||||
h.history = h.history.Next()
|
||||
|
||||
// Relay event 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes the message from the history buffer and instructs listeners to do the same.
|
||||
func (hub *Hub) Delete(mailbox string, id string) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
if h.history == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Locate and remove history entry.
|
||||
p := h.history
|
||||
end := p
|
||||
for {
|
||||
if next, ok := p.Next().Value.(event.MessageMetadata); ok {
|
||||
if mailbox == next.Mailbox && id == next.ID {
|
||||
p.Next().Value = nil
|
||||
break
|
||||
}
|
||||
}
|
||||
if p = p.Next(); p == end {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Relay event to all listeners, removing listeners if they return an error.
|
||||
for l := range h.listeners {
|
||||
if err := l.Delete(mailbox, id); 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.(event.MessageMetadata))
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (hub *Hub) runOp(op func(*Hub)) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if err, ok := r.(error); ok {
|
||||
log.Error().Str("module", "msghub").Err(err).Msg("Operation panicked")
|
||||
} else {
|
||||
log.Error().Str("module", "msghub").Err(err).Msgf("Operation panicked: %s", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
op(hub)
|
||||
}
|
||||
379
pkg/msghub/hub_test.go
Normal file
379
pkg/msghub/hub_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package msghub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension"
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testListener implements the Listener interface, mock for unit tests
|
||||
type testListener struct {
|
||||
messages []*event.MessageMetadata // received messages
|
||||
deletes []string // received deletes
|
||||
wantEvents int // how many events this listener wants to receive
|
||||
errorAfter int // when != 0, event count until Receive() begins returning error
|
||||
gotEvents int
|
||||
|
||||
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([]*event.MessageMetadata, 0, want*2),
|
||||
deletes: make([]string, 0, want*2),
|
||||
wantEvents: 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 event.MessageMetadata) error {
|
||||
l.gotEvents++
|
||||
l.messages = append(l.messages, &msg)
|
||||
if l.gotEvents == l.wantEvents {
|
||||
close(l.done)
|
||||
}
|
||||
if l.gotEvents == l.wantEvents+1 {
|
||||
close(l.overflow)
|
||||
}
|
||||
if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
|
||||
return fmt.Errorf("Too many messages")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *testListener) Delete(mailbox string, id string) error {
|
||||
l.gotEvents++
|
||||
l.deletes = append(l.deletes, mailbox+"/"+id)
|
||||
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.wantEvents)
|
||||
}
|
||||
|
||||
func TestHubNew(t *testing.T) {
|
||||
hub := New(5, extension.NewHost())
|
||||
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(0, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
for i := 0; i < 100; i++ {
|
||||
hub.Dispatch(m)
|
||||
}
|
||||
// Ensures Hub doesn't panic
|
||||
}
|
||||
|
||||
func TestHubZeroListeners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
for i := 0; i < 100; i++ {
|
||||
hub.Dispatch(m)
|
||||
}
|
||||
// Ensures Hub doesn't panic
|
||||
}
|
||||
|
||||
func TestHubOneListener(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
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(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
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(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
|
||||
// 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(100, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
l1 := newTestListener(3)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast 3 messages with no listeners
|
||||
msgs := make([]event.MessageMetadata, 3)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = event.MessageMetadata{
|
||||
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 TestHubHistoryDelete(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(100, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
l1 := newTestListener(3)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast 3 messages with no listeners
|
||||
msgs := make([]event.MessageMetadata, 3)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = event.MessageMetadata{
|
||||
Mailbox: "hub",
|
||||
ID: strconv.Itoa(i),
|
||||
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)
|
||||
}
|
||||
|
||||
hub.Delete("hub", "1") // Delete a message
|
||||
hub.Delete("zzz", "0") // Attempt to delete non-existent mailbox message
|
||||
|
||||
// Add a new listener, waits for 2 messages
|
||||
l2 := newTestListener(2)
|
||||
hub.AddListener(l2)
|
||||
|
||||
// Wait for messages (history)
|
||||
select {
|
||||
case <-l2.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout:", l2)
|
||||
}
|
||||
|
||||
want := []string{"subj 0", "subj 2"}
|
||||
for i := 0; i < len(want); i++ {
|
||||
got := l2.messages[i].Subject
|
||||
if got != want[i] {
|
||||
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHistoryReplayWrap(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
l1 := newTestListener(20)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast more messages than the hub can hold
|
||||
msgs := make([]event.MessageMetadata, 20)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = event.MessageMetadata{
|
||||
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 TestHubHistoryReplayWrapAfterDelete(t *testing.T) {
|
||||
bufferSize := 5
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(bufferSize, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
|
||||
waitForMessages := func(n int) {
|
||||
l := newTestListener(n)
|
||||
hub.AddListener(l)
|
||||
|
||||
select {
|
||||
case <-l.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout:", l)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast more messages than the hub can hold.
|
||||
msgs := make([]event.MessageMetadata, 10)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = event.MessageMetadata{
|
||||
Mailbox: "first",
|
||||
ID: strconv.Itoa(i),
|
||||
Subject: fmt.Sprintf("subj %v", i),
|
||||
}
|
||||
hub.Dispatch(msgs[i])
|
||||
}
|
||||
waitForMessages(bufferSize)
|
||||
|
||||
// Buffer must be configured size.
|
||||
require.Equal(t, bufferSize, hub.history.Len())
|
||||
|
||||
// Delete a message still present in buffer.
|
||||
hub.Delete("first", "7")
|
||||
|
||||
// Broadcast another set of messages.
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = event.MessageMetadata{
|
||||
Mailbox: "second",
|
||||
ID: strconv.Itoa(i),
|
||||
Subject: fmt.Sprintf("subj %v", i),
|
||||
}
|
||||
hub.Dispatch(msgs[i])
|
||||
}
|
||||
waitForMessages(bufferSize)
|
||||
|
||||
// Ensure the buffer did not shrink after delete.
|
||||
got := hub.history.Len()
|
||||
assert.Equal(t, bufferSize, got, "got buffer size %d, wanted %d", got, bufferSize)
|
||||
}
|
||||
|
||||
func TestHubContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
hub := New(5, extension.NewHost())
|
||||
go hub.Start(ctx)
|
||||
m := event.MessageMetadata{}
|
||||
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
|
||||
}
|
||||
}
|
||||
365
pkg/policy/address.go
Normal file
365
pkg/policy/address.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"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) {
|
||||
if a.Config.MailboxNaming == config.DomainNaming {
|
||||
return extractDomainMailbox(address)
|
||||
}
|
||||
|
||||
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.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. This is used for parsing RCPT TO arguments,
|
||||
// not To headers.
|
||||
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
|
||||
}
|
||||
return &Recipient{
|
||||
Address: mail.Address{Address: address},
|
||||
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 {
|
||||
ln := len(domain)
|
||||
if ln == 0 {
|
||||
return false
|
||||
}
|
||||
if ln > 255 {
|
||||
return false
|
||||
}
|
||||
if ln >= 4 && domain[0] == '[' && domain[ln-1] == ']' {
|
||||
// Bracketed domains must contain an IP address.
|
||||
s := 1
|
||||
if strings.HasPrefix(domain[1:], "IPv6:") {
|
||||
s = 6
|
||||
}
|
||||
ip := net.ParseIP(domain[s : ln-1])
|
||||
return ip != nil
|
||||
}
|
||||
|
||||
if domain[ln-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
|
||||
}
|
||||
|
||||
// Extracts the mailbox name when domain addressing is enabled.
|
||||
func extractDomainMailbox(address string) (string, error) {
|
||||
var local, domain string
|
||||
var err error
|
||||
|
||||
if address != "" && address[0] == '[' && address[len(address)-1] == ']' {
|
||||
// Likely an IP address in brackets, treat as domain only.
|
||||
domain = address
|
||||
} else {
|
||||
local, domain, err = parseEmailAddress(address)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if local != "" {
|
||||
local, err = parseMailboxName(local)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// If no @domain is specified, assume this is being used for mailbox lookup via the API.
|
||||
if domain == "" {
|
||||
domain = local
|
||||
}
|
||||
|
||||
if ValidateDomainPart(domain) == false {
|
||||
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||
}
|
||||
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Remove forward-path routes.
|
||||
if address[0] == '@' {
|
||||
end := strings.IndexRune(address, ':')
|
||||
if end == -1 {
|
||||
return "", "", fmt.Errorf("missing terminating ':' in route specification")
|
||||
}
|
||||
address = address[end+1:]
|
||||
if address == "" {
|
||||
return "", "", fmt.Errorf("Address empty after removing route specification")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
527
pkg/policy/address_test.go
Normal file
527
pkg/policy/address_test.go
Normal file
@@ -0,0 +1,527 @@
|
||||
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",
|
||||
},
|
||||
{
|
||||
input: "@host:user+label@domain.com",
|
||||
local: "user",
|
||||
full: "user@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "@a.com,@b.com:user+label@domain.com",
|
||||
local: "user",
|
||||
full: "user@domain.com",
|
||||
domain: "domain.com",
|
||||
},
|
||||
{
|
||||
input: "u@[127.0.0.1]",
|
||||
local: "u",
|
||||
full: "u@[127.0.0.1]",
|
||||
domain: "[127.0.0.1]",
|
||||
},
|
||||
{
|
||||
input: "u@[IPv6:2001:db8:aaaa:1::100]",
|
||||
local: "u",
|
||||
full: "u@[IPv6:2001:db8:aaaa:1::100]",
|
||||
domain: "[IPv6:2001:db8:aaaa:1::100]",
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test special cases with domain addressing mode.
|
||||
func TestExtractDomainMailboxValid(t *testing.T) {
|
||||
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||
|
||||
tests := map[string]struct {
|
||||
input string // Input to test
|
||||
domain string // Expected output when mailbox naming = domain
|
||||
}{
|
||||
"ipv4": {
|
||||
input: "[127.0.0.1]",
|
||||
domain: "[127.0.0.1]",
|
||||
},
|
||||
"medium ipv6": {
|
||||
input: "[IPv6:2001:db8:aaaa:1::100]",
|
||||
domain: "[IPv6:2001:db8:aaaa:1::100]",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
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"},
|
||||
{"[0.0.0.0]", true, "Single digit octet IP addr is valid"},
|
||||
{"[123.123.123.123]", true, "Multiple digit octet IP addr is valid"},
|
||||
{"[IPv6:2001:0db8:aaaa:0001:0000:0000:0000:0200]", true, "Full IPv6 addr is valid"},
|
||||
{"[IPv6:::1]", true, "Abbr IPv6 addr is valid"},
|
||||
}
|
||||
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"},
|
||||
{"@host:mailbox", true, "Forward-path routes are valid"},
|
||||
{"@a.com,@b.com:mailbox", true, "Multi-hop forward-path routes are valid"},
|
||||
{"@a.com,mailbox", false, "Unterminated forward-path routes are invalid"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecipientAddress verifies the Recipient.Address values returned by Addressing.NewRecipient.
|
||||
// This function parses a RCPT TO path, not a To header. See rfc5321#section-4.1.2
|
||||
func TestRecipientAddress(t *testing.T) {
|
||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||
|
||||
tests := map[string]string{
|
||||
"common": "user@example.com",
|
||||
"with label": "user+mailbox@example.com",
|
||||
"special chars": "a!#$%&'*+-/=?^_`{|}~@example.com",
|
||||
"ipv4": "user@[127.0.0.1]",
|
||||
"ipv6": "user@[IPv6:::1]",
|
||||
"route host": "@host:user@example.com",
|
||||
"route domain": "@route.com:user@example.com",
|
||||
"multi-hop route": "@first.com,@second.com:user@example.com",
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r, err := localPolicy.NewRecipient(tc)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse of %q failed: %v", tc, err)
|
||||
}
|
||||
|
||||
if got, want := r.Address.Address, tc; got != want {
|
||||
t.Errorf("Got Address: %q, want: %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
314
pkg/rest/apiv1_controller_test.go
Normal file
314
pkg/rest/apiv1_controller_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/inbucket/inbucket/pkg/extension/event"
|
||||
"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 := event.MessageMetadata{
|
||||
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 := event.MessageMetadata{
|
||||
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{MessageMetadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{MessageMetadata: 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(
|
||||
event.MessageMetadata{
|
||||
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 := event.MessageMetadata{
|
||||
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 := event.MessageMetadata{
|
||||
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{MessageMetadata: meta1})
|
||||
mm.AddMessage("good", &message.Message{MessageMetadata: 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()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user