mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-11-06 09:31:21 +00:00
Compare commits
545 Commits
v1.3.1
...
v3.1.0-beta2
| Author | SHA1 | Date | |
|---|---|---|---|
| 97506b2d2b | |||
| 8667c70257 | |||
| a27b57df67 | |||
| d8746e8093 | |||
| 599200d32e | |||
| e190adef4d | |||
| 6a389c78cc | |||
| 6d66012a0c | |||
| b5ccd3da51 | |||
| 2d409bb2b1 | |||
| 4262b34a20 | |||
| 746f3bffbd | |||
| 5a5864fde6 | |||
| e6e4e0987d | |||
| f0473c5d65 | |||
| 288288ee85 | |||
| 32b83e6345 | |||
| 043551343c | |||
| c1d5d49126 | |||
| d2121a52a9 | |||
| 3e94aacc20 | |||
| 41889ee83a | |||
| 1b5a783dbd | |||
| 208d20582e | |||
| 2a65c9beaa | |||
| f411a42f90 | |||
| 13c39c8c0f | |||
| 1088ccb8d1 | |||
| d304cbd88b | |||
| e56638cbac | |||
| 843cb8a015 | |||
| 535438342e | |||
| e22ed26633 | |||
| 1ce1674861 | |||
| 7ae7d29741 | |||
| 86d762ac88 | |||
| 85e1c2c7d7 | |||
| 3e06050771 | |||
| 72adb5561d | |||
| 20ef8af047 | |||
| d7c538a210 | |||
| ebd4b9504b | |||
| 1d102a68b8 | |||
| 11b581bbb5 | |||
| d1e52ad971 | |||
| 4a6b727cbc | |||
| 01fb161df8 | |||
| 0cb62af074 | |||
| 3731837127 | |||
| 74a27875e9 | |||
| b655c0cc11 | |||
| 163a84f353 | |||
| d6c23df241 | |||
| b1acff08a3 | |||
| 3d162549b1 | |||
| d2fad433d7 | |||
| beb5abc62d | |||
| 3709aa8b51 | |||
| 63e47a4e74 | |||
| 5eb9592637 | |||
| 9eabf94c48 | |||
| 0128be1f64 | |||
| d5553030d2 | |||
| f070347535 | |||
| cafd2c3d66 | |||
| dcd60b47dd | |||
| 6a30a294c6 | |||
| 9836c0ffbb | |||
| 558f3de083 | |||
| 00736cc704 | |||
| 9f0fef3180 | |||
| f1dadba1b2 | |||
| 06ec140e72 | |||
| 7c13a98ad2 | |||
| 0ae452ed17 | |||
| 926f9f3804 | |||
| 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 |
@@ -6,3 +6,8 @@ inbucket
|
|||||||
inbucket.exe
|
inbucket.exe
|
||||||
swaks-tests
|
swaks-tests
|
||||||
target
|
target
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
ui/dist
|
||||||
|
ui/elm-stuff
|
||||||
|
ui/node_modules
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Auto detect text files and perform LF normalization
|
# Auto detect text files and perform LF normalization
|
||||||
* text=auto
|
* text=auto
|
||||||
|
*.golden -text
|
||||||
*.raw -text
|
*.raw -text
|
||||||
|
|
||||||
# Custom for Visual Studio
|
# Custom for Visual Studio
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
name: Build and Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux-go-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Linux Go ${{ matrix.go }} build
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go:
|
||||||
|
- '1.21'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go }}
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- 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: Linux-Go-${{ matrix.go }}
|
||||||
|
parallel: true
|
||||||
|
|
||||||
|
windows-go-build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
name: Windows Go build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -race -coverprofile="profile.cov" ./...
|
||||||
|
|
||||||
|
- name: Send coverage
|
||||||
|
uses: shogo82148/actions-goveralls@v1
|
||||||
|
with:
|
||||||
|
path-to-profile: profile.cov
|
||||||
|
flag-name: Windows-Go
|
||||||
|
parallel: true
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
needs:
|
||||||
|
- linux-go-build
|
||||||
|
- windows-go-build
|
||||||
|
name: Test Coverage
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: shogo82148/actions-goveralls@v1
|
||||||
|
with:
|
||||||
|
parallel-finished: true
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request_review:
|
||||||
|
types:
|
||||||
|
- submitted
|
||||||
|
workflow_dispatch: # allow for manual run
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_PUSH: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: 'Build Container'
|
||||||
|
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: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
if: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||||
|
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: ${{ env.REGISTRY_PUSH == 'true' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Lint Go Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
golangci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v3
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
# Disable cache to prevent `File exists` errors.
|
||||||
|
# https://github.com/golangci/golangci-lint-action/issues/135
|
||||||
|
skip-pkg-cache: true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: 'Go Releaser'
|
||||||
|
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.21'
|
||||||
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20.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
-2
@@ -3,6 +3,9 @@
|
|||||||
*.a
|
*.a
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
# Emacs messiness.
|
||||||
|
*~
|
||||||
|
|
||||||
# Folders
|
# Folders
|
||||||
_obj
|
_obj
|
||||||
_test
|
_test
|
||||||
@@ -21,13 +24,44 @@ _testmain.go
|
|||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# vim swp files
|
# vim files
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
|
||||||
# our binaries
|
# Desktop Services Store on macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
/.direnv
|
||||||
|
|
||||||
|
# Inbucket binaries
|
||||||
|
/client
|
||||||
|
/client.exe
|
||||||
/inbucket
|
/inbucket
|
||||||
/inbucket.exe
|
/inbucket.exe
|
||||||
/dist/**
|
/dist/**
|
||||||
/cmd/client/client
|
/cmd/client/client
|
||||||
/cmd/client/client.exe
|
/cmd/client/client.exe
|
||||||
|
/cmd/inbucket/inbucket
|
||||||
|
/cmd/inbucket/inbucket.exe
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
.idea
|
||||||
|
inbucket.iml
|
||||||
|
|||||||
+49
-23
@@ -1,16 +1,20 @@
|
|||||||
project_name: inbucket
|
project_name: inbucket
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
owner: jhillyerd
|
owner: inbucket
|
||||||
name: inbucket
|
name: inbucket
|
||||||
name_template: '{{.Tag}}'
|
name_template: '{{.Tag}}'
|
||||||
brew:
|
|
||||||
commit_author:
|
before:
|
||||||
name: goreleaserbot
|
hooks:
|
||||||
email: goreleaser@carlosbecker.com
|
- go mod download
|
||||||
install: bin.install ""
|
|
||||||
builds:
|
builds:
|
||||||
- binary: inbucket
|
- id: inbucket
|
||||||
|
binary: inbucket
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -18,11 +22,16 @@ builds:
|
|||||||
- windows
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
|
- arm
|
||||||
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "7"
|
||||||
main: .
|
main: ./cmd/inbucket
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
- binary: client
|
- id: inbucket-client
|
||||||
|
binary: inbucket-client
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -30,15 +39,17 @@ builds:
|
|||||||
- windows
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
|
- arm
|
||||||
|
- arm64
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "7"
|
||||||
main: ./cmd/client
|
main: ./cmd/client
|
||||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
archive:
|
|
||||||
|
archives:
|
||||||
|
- id: tarball
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
|
||||||
.Arm }}{{ end }}'
|
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
format: zip
|
||||||
@@ -46,15 +57,30 @@ archive:
|
|||||||
- LICENSE*
|
- LICENSE*
|
||||||
- README*
|
- README*
|
||||||
- CHANGELOG*
|
- CHANGELOG*
|
||||||
- inbucket.bat
|
- etc/**
|
||||||
- etc/**/*
|
- ui/dist/**
|
||||||
- themes/**/*
|
- ui/greeting.html
|
||||||
fpm:
|
|
||||||
bindir: /usr/local/bin
|
nfpms:
|
||||||
snapshot:
|
- formats:
|
||||||
name_template: SNAPSHOT-{{ .Commit }}
|
- 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/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
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||||
|
|
||||||
dist: dist
|
dist: dist
|
||||||
sign:
|
|
||||||
artifacts: none
|
|
||||||
|
|||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
language: go
|
|
||||||
sudo: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
- DEPLOY_WITH_MAJOR="1.9"
|
|
||||||
|
|
||||||
before_script:
|
|
||||||
- go get github.com/golang/lint/golint
|
|
||||||
|
|
||||||
go:
|
|
||||||
- 1.9.x
|
|
||||||
- "1.10"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
provider: script
|
|
||||||
script: etc/travis-deploy.sh
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
branch: master
|
|
||||||
+277
-20
@@ -4,10 +4,248 @@ Change Log
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [v3.1.0-beta2] - 2024-02-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reject mail by origin domain: `INBUCKET_SMTP_REJECTORIGINDOMAINS` (#375)
|
||||||
|
- Wildcard support (#412)
|
||||||
|
- Version flag for `inbucket` cmd (#385)
|
||||||
|
- STLS support for POP3 (#384)
|
||||||
|
- ForceTLS flag for SMTP (#402)
|
||||||
|
- Lua scripting additions:
|
||||||
|
- `logger` API for Lua (#407)
|
||||||
|
- `before.message_stored` handler (#417, #418)
|
||||||
|
- `$` is replaced with `:` in filestore paths, for `D:\...` syntax (#449)
|
||||||
|
- REST Client `transport` support (#463)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- UI & Storage paths in systemd service file (#393)
|
||||||
|
- Web UI will redirect from `prefix` to `prefix/` (#397)
|
||||||
|
- Include inlines when listing attachments (#398)
|
||||||
|
- Fail Inbucket startup if unable to create storage dir (#448)
|
||||||
|
- Close directory file handles immediately, fixes Windows locking (#457)
|
||||||
|
|
||||||
|
|
||||||
|
## [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
|
## [v1.3.1] - 2018-03-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Adding additional locking during message delivery to prevent race condition
|
- Adding additional locking during message delivery to prevent race condition
|
||||||
that could lose messages.
|
that could lose messages.
|
||||||
|
|
||||||
@@ -31,7 +269,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||||
provides a more natural API
|
provides a more natural API
|
||||||
- Powerful command line REST
|
- Powerful command line REST
|
||||||
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
|
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
|
||||||
- Allow use of `latest` as a message ID in REST calls
|
- Allow use of `latest` as a message ID in REST calls
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -46,9 +284,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
### Added
|
### Added
|
||||||
- Storage of `To:` header in messages (likely breaks existing datastores)
|
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||||
- Attachment list to [GET message
|
- 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
|
- [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
|
- Monitor feature: lists messages as they arrive, regardless of their
|
||||||
destination mailbox
|
destination mailbox
|
||||||
- Make `@inbucket` mailbox prompt configurable
|
- Make `@inbucket` mailbox prompt configurable
|
||||||
@@ -111,30 +349,49 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Add Link button to messages, allows for directing another person to a
|
- Add Link button to messages, allows for directing another person to a
|
||||||
specific message.
|
specific message.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
|
||||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta2...main
|
||||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
[v3.1.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.1.0-beta1...v3.1.0-beta2
|
||||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
[v3.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v3.0.4...v3.1.0-beta1
|
||||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
|
||||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
|
||||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
|
||||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
|
||||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
|
||||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
[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:
|
2. Update CHANGELOG.md:
|
||||||
- Ensure *Unreleased* section is up to date
|
- Ensure *Unreleased* section is up to date
|
||||||
- Rename *Unreleased* section to release name and date.
|
- Rename *Unreleased* section to release name and date
|
||||||
- Add new GitHub `/compare` link
|
- Add new GitHub `/compare` link
|
||||||
|
- Update previous tag version for *Unreleased*
|
||||||
3. Run tests
|
3. Run tests
|
||||||
4. Test cross-compile: `goreleaser --snapshot`
|
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
|
||||||
5. Commit changes and merge release: `git flow release finish`
|
5. Commit changes and merge release into main, tag `vX.Y.Z`
|
||||||
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
|
6. Push tags and wait for
|
||||||
complete
|
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
|
||||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||||
|
|
||||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||||
|
|||||||
+6
-19
@@ -1,11 +1,8 @@
|
|||||||
How to Contribute
|
# How to Contribute
|
||||||
=================
|
|
||||||
|
|
||||||
Inbucket encourages third-party patches. It's valuable to know how other
|
Inbucket encourages third-party patches. It's valuable to know how other
|
||||||
developers are using the product.
|
developers are using the product.
|
||||||
|
|
||||||
**tl;dr:** File pull requests against the `develop` branch, not `master`!
|
|
||||||
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -17,28 +14,18 @@ to provide validation and/or guidance on your suggested approach.
|
|||||||
|
|
||||||
## Making Changes
|
## Making Changes
|
||||||
|
|
||||||
Inbucket uses [git-flow] with default options. If you have git-flow installed,
|
Inbucket follows the regular GitHub pattern. Create a topic branch from where
|
||||||
you can run `git flow feature start <topic branch name>`.
|
you want to base your work:
|
||||||
|
|
||||||
Without git-flow, create a topic branch from where you want to base your work:
|
|
||||||
- This is usually the `develop` branch, example command:
|
|
||||||
`git checkout origin/develop -b <topic branch name>`
|
|
||||||
- Only target the `master` branch if the issue is already resolved in
|
|
||||||
`develop`.
|
|
||||||
|
|
||||||
Once you are on your topic branch:
|
Once you are on your topic branch:
|
||||||
|
|
||||||
1. Make commits of logical units.
|
1. Make commits of logical units.
|
||||||
2. Add unit tests to exercise your changes.
|
2. Add unit tests to exercise your changes.
|
||||||
3. Run the updated code through `go fmt` and `go vet`.
|
3. Run `make` to test, vet and confirm your code is formatted correctly.
|
||||||
4. Ensure the code builds and tests with the following commands:
|
If you do not have Make installed, please perform these steps manually,
|
||||||
- `go clean ./...`
|
otherwise your PR will not pass our checks.
|
||||||
- `go build ./...`
|
|
||||||
- `go test ./...`
|
|
||||||
|
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
Thank you for contributing to Inbucket!
|
Thank you for contributing to Inbucket!
|
||||||
|
|
||||||
[git-flow]: https://github.com/nvie/gitflow
|
|
||||||
|
|||||||
+52
-19
@@ -1,25 +1,58 @@
|
|||||||
# Docker build file for Inbucket, see https://www.docker.io/
|
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||||
# Inbucket website: http://www.inbucket.org/
|
|
||||||
|
|
||||||
FROM golang:1.9-alpine
|
### Build frontend
|
||||||
MAINTAINER James Hillyerd, @jameshillyerd
|
# Due to no official elm compiler for arm; build frontend with amd64.
|
||||||
|
FROM --platform=linux/amd64 node:20 as frontend
|
||||||
|
RUN npm install -g node-gyp
|
||||||
|
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)
|
### Build backend
|
||||||
ENV INBUCKET_SRC $GOPATH/src/github.com/jhillyerd/inbucket
|
FROM golang:1.21-alpine3.19 as backend
|
||||||
ENV INBUCKET_HOME /opt/inbucket
|
RUN apk add --no-cache --virtual .build-deps g++ git make
|
||||||
WORKDIR $INBUCKET_HOME
|
WORKDIR /build
|
||||||
ENTRYPOINT ["/con/context/start-inbucket.sh"]
|
COPY . .
|
||||||
CMD ["/con/configuration/inbucket.conf"]
|
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.19
|
||||||
|
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
|
# Ports: SMTP, HTTP, POP3
|
||||||
EXPOSE 10025 10080 10110
|
EXPOSE 2500 9000 1100
|
||||||
|
|
||||||
# Persistent Volumes, following convention at:
|
# Persistent Volumes
|
||||||
# https://github.com/docker/docker/issues/9277
|
VOLUME /config
|
||||||
# NOTE /con/context is also used, not exposed by default
|
VOLUME /storage
|
||||||
VOLUME /con/configuration
|
|
||||||
VOLUME /con/data
|
|
||||||
|
|
||||||
# Build Inbucket
|
ENTRYPOINT ["/start-inbucket.sh"]
|
||||||
COPY . $INBUCKET_SRC/
|
CMD ["-logjson"]
|
||||||
RUN "$INBUCKET_SRC/etc/docker/install.sh"
|
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
PKG := inbucket
|
SHELL = /bin/sh
|
||||||
SHELL := /bin/sh
|
|
||||||
|
|
||||||
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
||||||
PKGS := $$(go list ./... | grep -v /vendor/)
|
PKGS := $(shell go list ./... | grep -v /vendor/)
|
||||||
|
|
||||||
.PHONY: all build clean fmt install lint simplify test
|
.PHONY: all build clean fmt lint reflex simplify test
|
||||||
|
|
||||||
all: test lint build
|
commands = client inbucket
|
||||||
|
|
||||||
|
all: clean test lint build
|
||||||
|
|
||||||
|
$(commands): %: cmd/% $(SRC)
|
||||||
|
go build ./$<
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
go clean
|
go clean $(PKGS)
|
||||||
|
rm -f $(commands)
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
go get -t ./...
|
go get -t ./...
|
||||||
|
|
||||||
build: clean deps
|
build: $(commands)
|
||||||
go build
|
|
||||||
|
|
||||||
install: build
|
test:
|
||||||
go install
|
|
||||||
|
|
||||||
test: clean deps
|
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@@ -30,6 +32,12 @@ simplify:
|
|||||||
@gofmt -s -l -w $(SRC)
|
@gofmt -s -l -w $(SRC)
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
|
@echo "gofmt check..."
|
||||||
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
||||||
@golint -set_exit_status $${PKGS}
|
@echo "golint check..."
|
||||||
@go vet $${PKGS}
|
@golint -set_exit_status $(PKGS)
|
||||||
|
@echo "go vet check..."
|
||||||
|
@go vet $(PKGS)
|
||||||
|
|
||||||
|
reflex:
|
||||||
|
reflex -r '\.go$$' -- sh -c 'echo; date; echo; go test ./... && echo ALL PASS'
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
Inbucket
|

|
||||||
=============================================================================
|

|
||||||
[][Build Status]
|
|
||||||
|
# Inbucket
|
||||||
|
|
||||||
Inbucket is an email testing service; it will accept messages for any email
|
Inbucket is an email testing service; it will accept messages for any email
|
||||||
address and make them available via web, REST and POP3. Once compiled,
|
address and make them available via web, REST and POP3 interfaces. Once
|
||||||
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
compiled, Inbucket does not have any external dependencies - HTTP, SMTP, POP3
|
||||||
are all built in).
|
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]
|
Read more at the [Inbucket Website]
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
## Development Status
|
## Development Status
|
||||||
|
|
||||||
Inbucket is currently production quality: it is being used for real work.
|
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].
|
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],
|
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
|
||||||
see the `README.md` there for installation instructions.
|
tracks our tagged releases, and `edge` tracks our potentially unstable
|
||||||
|
`main` branch.
|
||||||
|
|
||||||
|
|
||||||
## Building from Source
|
## 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
|
### Configure and Launch
|
||||||
Unix and OS X machines as is. Launch the daemon:
|
|
||||||
|
|
||||||
$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
|
By default the SMTP server will be listening on localhost port 2500 and
|
||||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||||
|
|
||||||
The Inbucket website has a more complete guide to
|
See doc/[config.md] for more information on configuring Inbucket, but you will
|
||||||
[installing from source][From Source]
|
likely find the [Configurator] tool the easiest way to generate a configuration.
|
||||||
|
|
||||||
|
|
||||||
## About
|
## 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
|
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
|
[Build Status]: https://travis-ci.org/inbucket/inbucket
|
||||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
|
||||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
|
||||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
[Configurator]: https://www.inbucket.org/configurator/
|
||||||
[Google Go]: http://golang.org/
|
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
|
||||||
|
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
|
||||||
|
[Docker Image]: https://inbucket.org/packages/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]: http://brew.sh/
|
||||||
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
|
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
|
||||||
[Inbucket Website]: http://www.inbucket.org/
|
[Inbucket Website]: https://www.inbucket.org/
|
||||||
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
|
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
|
||||||
|
[Node.js]: https://nodejs.org/en/
|
||||||
|
|||||||
+3
-6
@@ -6,12 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/rest/client"
|
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listCmd struct {
|
type listCmd struct{}
|
||||||
mailbox string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*listCmd) Name() string {
|
func (*listCmd) Name() string {
|
||||||
return "list"
|
return "list"
|
||||||
@@ -27,8 +25,7 @@ func (*listCmd) Usage() string {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *listCmd) SetFlags(f *flag.FlagSet) {
|
func (l *listCmd) SetFlags(f *flag.FlagSet) {}
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listCmd) Execute(
|
func (l *listCmd) Execute(
|
||||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
|
|||||||
@@ -50,14 +50,17 @@ func main() {
|
|||||||
// Important top-level flags
|
// Important top-level flags
|
||||||
subcommands.ImportantFlag("host")
|
subcommands.ImportantFlag("host")
|
||||||
subcommands.ImportantFlag("port")
|
subcommands.ImportantFlag("port")
|
||||||
|
|
||||||
// Setup standard helpers
|
// Setup standard helpers
|
||||||
subcommands.Register(subcommands.HelpCommand(), "")
|
subcommands.Register(subcommands.HelpCommand(), "")
|
||||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||||
|
|
||||||
// Setup my commands
|
// Setup my commands
|
||||||
subcommands.Register(&listCmd{}, "")
|
subcommands.Register(&listCmd{}, "")
|
||||||
subcommands.Register(&matchCmd{}, "")
|
subcommands.Register(&matchCmd{}, "")
|
||||||
subcommands.Register(&mboxCmd{}, "")
|
subcommands.Register(&mboxCmd{}, "")
|
||||||
|
|
||||||
// Parse and execute
|
// Parse and execute
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
+1
-2
@@ -10,11 +10,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/rest/client"
|
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type matchCmd struct {
|
type matchCmd struct {
|
||||||
mailbox string
|
|
||||||
output string
|
output string
|
||||||
outFunc func(headers []*client.MessageHeader) error
|
outFunc func(headers []*client.MessageHeader) error
|
||||||
delete bool
|
delete bool
|
||||||
|
|||||||
+5
-3
@@ -7,11 +7,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/rest/client"
|
"github.com/inbucket/inbucket/v3/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mboxCmd struct {
|
type mboxCmd struct {
|
||||||
mailbox string
|
|
||||||
delete bool
|
delete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +72,12 @@ func outputMbox(headers []*client.MessageHeader) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Get source REST failed: %v", err)
|
return fmt.Errorf("Get source REST failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("From %s\n", h.From)
|
fmt.Printf("From %s\n", h.From)
|
||||||
// TODO Escape "From " in message bodies with >
|
// TODO Escape "From " in message bodies with >
|
||||||
source.WriteTo(os.Stdout)
|
if _, err := source.WriteTo(os.Stdout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// 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/v3/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/server"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/storage/file"
|
||||||
|
"github.com/inbucket/inbucket/v3/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.")
|
||||||
|
versionflag := flag.Bool("version", false, "Displays version.")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if *versionflag {
|
||||||
|
fmt.Fprintln(os.Stdout, version)
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// Package datastore contains implementation independent datastore logic
|
|
||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/mail"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/enmime"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrNotExist indicates the requested message does not exist
|
|
||||||
ErrNotExist = errors.New("Message does not exist")
|
|
||||||
|
|
||||||
// ErrNotWritable indicates the message is closed; no longer writable
|
|
||||||
ErrNotWritable = errors.New("Message not writable")
|
|
||||||
)
|
|
||||||
|
|
||||||
// DataStore is an interface to get Mailboxes stored in Inbucket
|
|
||||||
type DataStore interface {
|
|
||||||
MailboxFor(emailAddress string) (Mailbox, error)
|
|
||||||
AllMailboxes() ([]Mailbox, error)
|
|
||||||
// LockFor is a temporary hack to fix #77 until Datastore revamp
|
|
||||||
LockFor(emailAddress string) (*sync.RWMutex, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mailbox is an interface to get and manipulate messages in a DataStore
|
|
||||||
type Mailbox interface {
|
|
||||||
GetMessages() ([]Message, error)
|
|
||||||
GetMessage(id string) (Message, error)
|
|
||||||
Purge() error
|
|
||||||
NewMessage() (Message, error)
|
|
||||||
Name() string
|
|
||||||
String() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message is an interface for a single message in a Mailbox
|
|
||||||
type Message interface {
|
|
||||||
ID() string
|
|
||||||
From() string
|
|
||||||
To() []string
|
|
||||||
Date() time.Time
|
|
||||||
Subject() string
|
|
||||||
RawReader() (reader io.ReadCloser, err error)
|
|
||||||
ReadHeader() (msg *mail.Message, err error)
|
|
||||||
ReadBody() (body *enmime.Envelope, err error)
|
|
||||||
ReadRaw() (raw *string, err error)
|
|
||||||
Append(data []byte) error
|
|
||||||
Close() error
|
|
||||||
Delete() error
|
|
||||||
String() string
|
|
||||||
Size() int64
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type HashLock [4096]sync.RWMutex
|
|
||||||
|
|
||||||
func (h *HashLock) Get(hash string) *sync.RWMutex {
|
|
||||||
if len(hash) < 3 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
i, err := strconv.ParseInt(hash[0:3], 16, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &h[i]
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"expvar"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
retentionScanCompleted = time.Now()
|
|
||||||
retentionScanCompletedMu sync.RWMutex
|
|
||||||
|
|
||||||
// History counters
|
|
||||||
expRetentionDeletesTotal = new(expvar.Int)
|
|
||||||
expRetentionPeriod = new(expvar.Int)
|
|
||||||
expRetainedCurrent = new(expvar.Int)
|
|
||||||
|
|
||||||
// History of certain stats
|
|
||||||
retentionDeletesHist = list.New()
|
|
||||||
retainedHist = list.New()
|
|
||||||
|
|
||||||
// History rendered as comma delimited string
|
|
||||||
expRetentionDeletesHist = new(expvar.String)
|
|
||||||
expRetainedHist = new(expvar.String)
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rm := expvar.NewMap("retention")
|
|
||||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
|
||||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
|
||||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
|
||||||
rm.Set("Period", expRetentionPeriod)
|
|
||||||
rm.Set("RetainedHist", expRetainedHist)
|
|
||||||
rm.Set("RetainedCurrent", expRetainedCurrent)
|
|
||||||
|
|
||||||
log.AddTickerFunc(func() {
|
|
||||||
expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal))
|
|
||||||
expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetentionScanner looks for messages older than the configured retention period and deletes them.
|
|
||||||
type RetentionScanner struct {
|
|
||||||
globalShutdown chan bool // Closes when Inbucket needs to shut down
|
|
||||||
retentionShutdown chan bool // Closed after the scanner has shut down
|
|
||||||
ds DataStore
|
|
||||||
retentionPeriod time.Duration
|
|
||||||
retentionSleep time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRetentionScanner launches a go-routine that scans for expired
|
|
||||||
// messages, following the configured interval
|
|
||||||
func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner {
|
|
||||||
cfg := config.GetDataStoreConfig()
|
|
||||||
rs := &RetentionScanner{
|
|
||||||
globalShutdown: shutdownChannel,
|
|
||||||
retentionShutdown: make(chan bool),
|
|
||||||
ds: ds,
|
|
||||||
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
|
|
||||||
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
|
|
||||||
}
|
|
||||||
// expRetentionPeriod is displayed on the status page
|
|
||||||
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start up the retention scanner if retention period > 0
|
|
||||||
func (rs *RetentionScanner) Start() {
|
|
||||||
if rs.retentionPeriod <= 0 {
|
|
||||||
log.Infof("Retention scanner disabled")
|
|
||||||
close(rs.retentionShutdown)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Infof("Retention configured for %v", rs.retentionPeriod)
|
|
||||||
go rs.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// run loops to kick off the scanner on the correct schedule
|
|
||||||
func (rs *RetentionScanner) run() {
|
|
||||||
start := time.Now()
|
|
||||||
retentionLoop:
|
|
||||||
for {
|
|
||||||
// Prevent scanner from starting more than once a minute
|
|
||||||
since := time.Since(start)
|
|
||||||
if since < time.Minute {
|
|
||||||
dur := time.Minute - since
|
|
||||||
log.Tracef("Retention scanner sleeping for %v", dur)
|
|
||||||
select {
|
|
||||||
case <-rs.globalShutdown:
|
|
||||||
break retentionLoop
|
|
||||||
case <-time.After(dur):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Kickoff scan
|
|
||||||
start = time.Now()
|
|
||||||
if err := rs.doScan(); err != nil {
|
|
||||||
log.Errorf("Error during retention scan: %v", err)
|
|
||||||
}
|
|
||||||
// Check for global shutdown
|
|
||||||
select {
|
|
||||||
case <-rs.globalShutdown:
|
|
||||||
break retentionLoop
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Tracef("Retention scanner shut down")
|
|
||||||
close(rs.retentionShutdown)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doScan does a single pass of all mailboxes looking for messages that can be purged
|
|
||||||
func (rs *RetentionScanner) doScan() error {
|
|
||||||
log.Tracef("Starting retention scan")
|
|
||||||
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
|
||||||
mboxes, err := rs.ds.AllMailboxes()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
retained := 0
|
|
||||||
// Loop over all mailboxes
|
|
||||||
for _, mb := range mboxes {
|
|
||||||
messages, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Loop over all messages in mailbox
|
|
||||||
for _, msg := range messages {
|
|
||||||
if msg.Date().Before(cutoff) {
|
|
||||||
log.Tracef("Purging expired message %v", msg.ID())
|
|
||||||
err = msg.Delete()
|
|
||||||
if err != nil {
|
|
||||||
// Log but don't abort
|
|
||||||
log.Errorf("Failed to purge message %v: %v", msg.ID(), err)
|
|
||||||
} else {
|
|
||||||
expRetentionDeletesTotal.Add(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
retained++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sleep after completing a mailbox
|
|
||||||
select {
|
|
||||||
case <-rs.globalShutdown:
|
|
||||||
log.Tracef("Retention scan aborted due to shutdown")
|
|
||||||
return nil
|
|
||||||
case <-time.After(rs.retentionSleep):
|
|
||||||
// Reduce disk thrashing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Update metrics
|
|
||||||
setRetentionScanCompleted(time.Now())
|
|
||||||
expRetainedCurrent.Set(int64(retained))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join does not retun until the retention scanner has shut down
|
|
||||||
func (rs *RetentionScanner) Join() {
|
|
||||||
if rs.retentionShutdown != nil {
|
|
||||||
<-rs.retentionShutdown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRetentionScanCompleted(t time.Time) {
|
|
||||||
retentionScanCompletedMu.Lock()
|
|
||||||
defer retentionScanCompletedMu.Unlock()
|
|
||||||
retentionScanCompleted = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRetentionScanCompleted() time.Time {
|
|
||||||
retentionScanCompletedMu.RLock()
|
|
||||||
defer retentionScanCompletedMu.RUnlock()
|
|
||||||
return retentionScanCompleted
|
|
||||||
}
|
|
||||||
|
|
||||||
func secondsSinceRetentionScanCompleted() interface{} {
|
|
||||||
return time.Since(getRetentionScanCompleted()) / time.Second
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDoRetentionScan(t *testing.T) {
|
|
||||||
// Create mock objects
|
|
||||||
mds := &MockDataStore{}
|
|
||||||
|
|
||||||
mb1 := &MockMailbox{}
|
|
||||||
mb2 := &MockMailbox{}
|
|
||||||
mb3 := &MockMailbox{}
|
|
||||||
|
|
||||||
// Mockup some different aged messages (num is in hours)
|
|
||||||
new1 := mockMessage(0)
|
|
||||||
new2 := mockMessage(1)
|
|
||||||
new3 := mockMessage(2)
|
|
||||||
old1 := mockMessage(4)
|
|
||||||
old2 := mockMessage(12)
|
|
||||||
old3 := mockMessage(24)
|
|
||||||
|
|
||||||
// First it should ask for all mailboxes
|
|
||||||
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
|
|
||||||
|
|
||||||
// Then for all messages on each box
|
|
||||||
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
|
|
||||||
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
|
|
||||||
mb3.On("GetMessages").Return([]Message{new3}, nil)
|
|
||||||
|
|
||||||
// Test 4 hour retention
|
|
||||||
rs := &RetentionScanner{
|
|
||||||
ds: mds,
|
|
||||||
retentionPeriod: 4*time.Hour - time.Minute,
|
|
||||||
retentionSleep: 0,
|
|
||||||
}
|
|
||||||
if err := rs.doScan(); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check our assertions
|
|
||||||
mds.AssertExpectations(t)
|
|
||||||
mb1.AssertExpectations(t)
|
|
||||||
mb2.AssertExpectations(t)
|
|
||||||
mb3.AssertExpectations(t)
|
|
||||||
|
|
||||||
// Delete should not have been called on new messages
|
|
||||||
new1.AssertNotCalled(t, "Delete")
|
|
||||||
new2.AssertNotCalled(t, "Delete")
|
|
||||||
new3.AssertNotCalled(t, "Delete")
|
|
||||||
|
|
||||||
// Delete should have been called once on old messages
|
|
||||||
old1.AssertNumberOfCalls(t, "Delete", 1)
|
|
||||||
old2.AssertNumberOfCalls(t, "Delete", 1)
|
|
||||||
old3.AssertNumberOfCalls(t, "Delete", 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a MockMessage of a specific age
|
|
||||||
func mockMessage(ageHours int) *MockMessage {
|
|
||||||
msg := &MockMessage{}
|
|
||||||
msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
|
|
||||||
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
|
|
||||||
msg.On("Delete").Return(nil)
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package datastore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/mail"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/enmime"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockDataStore is a shared mock for unit testing
|
|
||||||
type MockDataStore struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxFor mock function
|
|
||||||
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
|
|
||||||
args := m.Called(name)
|
|
||||||
return args.Get(0).(Mailbox), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllMailboxes mock function
|
|
||||||
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).([]Mailbox), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDataStore) LockFor(name string) (*sync.RWMutex, error) {
|
|
||||||
return &sync.RWMutex{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockMailbox is a shared mock for unit testing
|
|
||||||
type MockMailbox struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages mock function
|
|
||||||
func (m *MockMailbox) GetMessages() ([]Message, error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).([]Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessage mock function
|
|
||||||
func (m *MockMailbox) GetMessage(id string) (Message, error) {
|
|
||||||
args := m.Called(id)
|
|
||||||
return args.Get(0).(Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge mock function
|
|
||||||
func (m *MockMailbox) Purge() error {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessage mock function
|
|
||||||
func (m *MockMailbox) NewMessage() (Message, error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name mock function
|
|
||||||
func (m *MockMailbox) Name() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String mock function
|
|
||||||
func (m *MockMailbox) String() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockMessage is a shared mock for unit testing
|
|
||||||
type MockMessage struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID mock function
|
|
||||||
func (m *MockMessage) ID() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// From mock function
|
|
||||||
func (m *MockMessage) From() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// To mock function
|
|
||||||
func (m *MockMessage) To() []string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).([]string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date mock function
|
|
||||||
func (m *MockMessage) Date() time.Time {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(time.Time)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject mock function
|
|
||||||
func (m *MockMessage) Subject() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHeader mock function
|
|
||||||
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(*mail.Message), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBody mock function
|
|
||||||
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(*enmime.Envelope), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRaw mock function
|
|
||||||
func (m *MockMessage) ReadRaw() (raw *string, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(*string), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RawReader mock function
|
|
||||||
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Get(0).(io.ReadCloser), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size mock function
|
|
||||||
func (m *MockMessage) Size() int64 {
|
|
||||||
args := m.Called()
|
|
||||||
return int64(args.Int(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append mock function
|
|
||||||
func (m *MockMessage) Append(data []byte) error {
|
|
||||||
// []byte arg seems to mess up testify/mock
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close mock function
|
|
||||||
func (m *MockMessage) Close() error {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete mock function
|
|
||||||
func (m *MockMessage) Delete() error {
|
|
||||||
args := m.Called()
|
|
||||||
return args.Error(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String mock function
|
|
||||||
func (m *MockMessage) String() string {
|
|
||||||
args := m.Called()
|
|
||||||
return args.String(0)
|
|
||||||
}
|
|
||||||
+489
@@ -0,0 +1,489 @@
|
|||||||
|
# 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_REJECTORIGINDOMAINS Domains to reject mail from
|
||||||
|
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 recipient 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 recipient domains
|
||||||
|
- Example: `reject.com,gmail.com`
|
||||||
|
|
||||||
|
### Rejected Origin Domain List
|
||||||
|
|
||||||
|
`INBUCKET_SMTP_REJECTORIGINDOMAINS`
|
||||||
|
|
||||||
|
List of domains to reject mail from. This list is enforced regardless of the
|
||||||
|
`INBUCKET_SMTP_DEFAULTACCEPT` value.
|
||||||
|
|
||||||
|
Enforcement takes place during evalation of the `MAIL FROM` SMTP command, the
|
||||||
|
origin domain is extracted from the address presented and compared against the
|
||||||
|
list. It does not take email headers into account.
|
||||||
|
|
||||||
|
- Default: None
|
||||||
|
- Values: Comma separated list of origin 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 recipient 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 recipient 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. `$` characters will be replaced with `:` in the final path value,
|
||||||
|
allowing Windows drive letters, i.e. `D$\inbucket`.
|
||||||
|
|
||||||
|
#### `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
|
||||||
Executable
+36
@@ -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 'yarn build' from the 'ui' directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec ./inbucket $*
|
||||||
-129
@@ -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
|
<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>
|
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>
|
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
|
<p>This instance of Inbucket is running inside of a <a
|
||||||
@@ -11,5 +13,7 @@ of 300 messages per mailbox - the oldest messages will be deleted to stay under
|
|||||||
that limit.</p>
|
that limit.</p>
|
||||||
|
|
||||||
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
|
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
|
||||||
domain will be accepted but not written to disk. Use this domain for load or
|
domain will be accepted, but immediately <b>discarded</b> without being written
|
||||||
soak testing your application.</p>
|
to disk. Use this domain for load or soak testing your application. Inbucket
|
||||||
|
will retain mail for any other domain by default, i.e.
|
||||||
|
<code>@inbucket.local</code>.</p>
|
||||||
|
|||||||
@@ -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
|
# start-inbucket.sh
|
||||||
# description: start inbucket (runs within a docker container)
|
# description: start inbucket (runs within a docker container)
|
||||||
|
|
||||||
|
INBUCKET_HOME="/opt/inbucket"
|
||||||
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
||||||
CONF_TARGET="/con/configuration"
|
CONF_TARGET="/config"
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ install_default_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_default_config "inbucket.conf"
|
|
||||||
install_default_config "greeting.html"
|
install_default_config "greeting.html"
|
||||||
|
|
||||||
exec "$INBUCKET_HOME/bin/inbucket" $*
|
exec "$INBUCKET_HOME/bin/inbucket" $*
|
||||||
|
|||||||
+22
-10
@@ -3,7 +3,7 @@
|
|||||||
# description: Launch Inbucket's docker image
|
# description: Launch Inbucket's docker image
|
||||||
|
|
||||||
# Docker Image Tag
|
# Docker Image Tag
|
||||||
IMAGE="jhillyerd/inbucket"
|
IMAGE="inbucket/inbucket:edge"
|
||||||
|
|
||||||
# Ports exposed on host:
|
# Ports exposed on host:
|
||||||
PORT_HTTP=9000
|
PORT_HTTP=9000
|
||||||
@@ -12,9 +12,9 @@ PORT_POP3=1100
|
|||||||
|
|
||||||
# Volumes exposed on host:
|
# Volumes exposed on host:
|
||||||
VOL_CONFIG="/tmp/inbucket/config"
|
VOL_CONFIG="/tmp/inbucket/config"
|
||||||
VOL_DATA="/tmp/inbucket/data"
|
VOL_DATA="/tmp/inbucket/storage"
|
||||||
|
|
||||||
set -eo pipefail
|
set -e
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local run_opts=""
|
local run_opts=""
|
||||||
@@ -25,6 +25,9 @@ main() {
|
|||||||
usage
|
usage
|
||||||
exit
|
exit
|
||||||
;;
|
;;
|
||||||
|
-b)
|
||||||
|
build
|
||||||
|
;;
|
||||||
-r)
|
-r)
|
||||||
reset
|
reset
|
||||||
;;
|
;;
|
||||||
@@ -38,25 +41,34 @@ main() {
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
set -x
|
||||||
|
|
||||||
docker run $run_opts \
|
docker run $run_opts \
|
||||||
-p $PORT_HTTP:10080 \
|
-p $PORT_HTTP:9000 \
|
||||||
-p $PORT_SMTP:10025 \
|
-p $PORT_SMTP:2500 \
|
||||||
-p $PORT_POP3:10110 \
|
-p $PORT_POP3:1100 \
|
||||||
-v "$VOL_CONFIG:/con/configuration" \
|
-v "$VOL_CONFIG:/config" \
|
||||||
-v "$VOL_DATA:/con/data" \
|
-v "$VOL_DATA:/storage" \
|
||||||
"$IMAGE"
|
"$IMAGE"
|
||||||
}
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "$0 [options]" 2>&1
|
echo "$0 [options]" 2>&1
|
||||||
|
echo " -b build - build image before starting" 2>&1
|
||||||
echo " -d detach - detach and print container ID" 2>&1
|
echo " -d detach - detach and print container ID" 2>&1
|
||||||
echo " -r reset - purge config and data before startup" 2>&1
|
echo " -r reset - purge config and data before startup" 2>&1
|
||||||
echo " -h help - print this message" 2>&1
|
echo " -h help - print this message" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
echo "Building $IMAGE"
|
||||||
|
docker build . -t "$IMAGE"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
/bin/rm -rf "$VOL_CONFIG"
|
rm -rf "$VOL_CONFIG"
|
||||||
/bin/rm -rf "$VOL_DATA"
|
rm -rf "$VOL_DATA"
|
||||||
}
|
}
|
||||||
|
|
||||||
main $*
|
main $*
|
||||||
|
|||||||
@@ -1,51 +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"
|
|
||||||
cd "$srcdir"
|
|
||||||
# Fetch tags for describe
|
|
||||||
git fetch -t
|
|
||||||
builddate="$(date -Iseconds)"
|
|
||||||
buildver="$(git describe --tags --always)"
|
|
||||||
|
|
||||||
# Build
|
|
||||||
go clean
|
|
||||||
echo "### Fetching Dependencies"
|
|
||||||
go get -t -v ./...
|
|
||||||
|
|
||||||
echo "### Testing Inbucket"
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
echo "### Building Inbucket"
|
|
||||||
go build -o inbucket -ldflags "-X 'main.version=$buildver' -X 'main.date=$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
|
|
||||||
@@ -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/share/inbucket/ui
|
||||||
|
Environment=INBUCKET_WEB_GREETINGFILE=/etc/inbucket/greeting.html
|
||||||
|
Environment=INBUCKET_STORAGE_TYPE=file
|
||||||
|
Environment=INBUCKET_STORAGE_PARAMS=path:/var/inbucket
|
||||||
|
|
||||||
|
# Uncomment line below to use low numbered ports
|
||||||
|
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/bin/inbucket
|
||||||
|
|
||||||
|
ExecStartPre=/bin/mkdir -p /var/inbucket
|
||||||
|
ExecStartPre=/bin/chown daemon:daemon /var/inbucket
|
||||||
|
|
||||||
|
ExecStart=/usr/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
-1
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# rest-apiv1.sh
|
# rest-apiv1.sh
|
||||||
# description: Script to access Inbucket REST API version 1
|
# description: Script to access Inbucket REST API version 1
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
Subject: Inline attachment
|
||||||
|
From: %FROM_ADDRESS%
|
||||||
|
To: %TO_ADDRESS%
|
||||||
|
Message-ID: <1234@example.com>
|
||||||
|
Date: %DATE%
|
||||||
|
Content-Type: multipart/mixed; boundary=boundary1
|
||||||
|
|
||||||
|
--boundary1
|
||||||
|
Content-Type: text/html; charset=utf-8
|
||||||
|
Content-Transfer-Encoding: quoted-printable
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Hello World HTML</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style=3D"color:red">Hello World</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
--boundary1
|
||||||
|
Content-Type: application/pdf; name=Hello-World.pdf
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: inline; name=Hello-World.pdf;
|
||||||
|
filename=Hello-World.pdf
|
||||||
|
|
||||||
|
JVBERi0xLjQKJcK1wrYKCjEgMCBvYmoKPDwvVGl0bGUoSGVsbG8gV29ybGQpL0F1dGhvcihBZHJp
|
||||||
|
dW0pPj4KZW5kb2JqCgoyIDAgb2JqCjw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAzIDAgUj4+CmVuZG9i
|
||||||
|
agoKMyAwIG9iago8PC9UeXBlL1BhZ2VzL01lZGlhQm94WzAgMCA1OTUgODQyXS9SZXNvdXJjZXM8
|
||||||
|
PC9Gb250PDwvRjEgNCAwIFI+Pi9Qcm9jU2V0Wy9QREYvVGV4dF0+Pi9LaWRzWzUgMCBSXS9Db3Vu
|
||||||
|
dCAxPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvRm9udC9TdWJ0eXBlL1R5cGUxL0Jhc2VGb250
|
||||||
|
L0hlbHZldGljYS9FbmNvZGluZy9XaW5BbnNpRW5jb2Rpbmc+PgplbmRvYmoKCjUgMCBvYmoKPDwv
|
||||||
|
VHlwZS9QYWdlL1BhcmVudCAzIDAgUi9Db250ZW50cyA2IDAgUj4+CmVuZG9iagoKNiAwIG9iago8
|
||||||
|
PC9MZW5ndGggNTEvRmlsdGVyL0ZsYXRlRGVjb2RlPj4Kc3RyZWFtCnic03czVDCxUAhJ43IK4TI3
|
||||||
|
UjA3MVMISeHS8EjNyclXCM8vyknRVAjJ4nIN4QIA3FcKuwplbmRzdHJlYW0KZW5kb2JqCgp4cmVm
|
||||||
|
CjAgNwowMDAwMDAwMDAwIDY1NTM2IGYgCjAwMDAwMDAwMTYgMDAwMDAgbiAKMDAwMDAwMDA3MSAw
|
||||||
|
MDAwMCBuIAowMDAwMDAwMTE3IDAwMDAwIG4gCjAwMDAwMDAyNDIgMDAwMDAgbiAKMDAwMDAwMDMz
|
||||||
|
MSAwMDAwMCBuIAowMDAwMDAwMzkwIDAwMDAwIG4gCgp0cmFpbGVyCjw8L1NpemUgNy9JbmZvIDEg
|
||||||
|
MCBSL1Jvb3QgMiAwIFI+PgpzdGFydHhyZWYKNTA5CiUlRU9GCg==
|
||||||
|
|
||||||
|
|
||||||
|
--boundary1--
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# run-tests.sh
|
# run-tests.sh
|
||||||
# description: Generate test emails for Inbucket
|
# description: Generate test emails for Inbucket
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
esac
|
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"
|
export SWAKS_OPT_to="$to@inbucket.local"
|
||||||
|
|
||||||
# Basic test
|
# Basic test
|
||||||
@@ -56,3 +56,13 @@ swaks $* --data outlook.raw
|
|||||||
# Non-mime responsive HTML test
|
# Non-mime responsive HTML test
|
||||||
swaks $* --data nonmime-html-responsive.raw
|
swaks $* --data nonmime-html-responsive.raw
|
||||||
swaks $* --data nonmime-html-inlined.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
|
||||||
|
|
||||||
|
# Inline attachment test
|
||||||
|
swaks $* --data mime-inline.raw
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
Date: %DATE%
|
Date: %DATE%
|
||||||
To: %TO_ADDRESS%
|
To: %TO_ADDRESS%,
|
||||||
From: %FROM_ADDRESS%
|
=?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
|
||||||
|
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?=
|
||||||
|
<fromuser@inbucket.org>
|
||||||
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||||
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||||
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# travis-deploy.sh
|
|
||||||
# description: Trigger goreleaser deployment in correct build scenarios
|
|
||||||
|
|
||||||
set -eo pipefail
|
|
||||||
set -x
|
|
||||||
|
|
||||||
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
|
|
||||||
curl -sL https://git.io/goreleaser | bash
|
|
||||||
fi
|
|
||||||
@@ -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
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
package filestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/mail"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/enmime"
|
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileMessage implements Message and contains a little bit of data about a
|
|
||||||
// particular email message, and methods to retrieve the rest of it from disk.
|
|
||||||
type FileMessage struct {
|
|
||||||
mailbox *FileMailbox
|
|
||||||
// Stored in GOB
|
|
||||||
Fid string
|
|
||||||
Fdate time.Time
|
|
||||||
Ffrom string
|
|
||||||
Fto []string
|
|
||||||
Fsubject string
|
|
||||||
Fsize int64
|
|
||||||
// These are for creating new messages only
|
|
||||||
writable bool
|
|
||||||
writerFile *os.File
|
|
||||||
writer *bufio.Writer
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessage creates a new FileMessage object and sets the Date and Id fields.
|
|
||||||
// It will also delete messages over messageCap if configured.
|
|
||||||
func (mb *FileMailbox) NewMessage() (datastore.Message, error) {
|
|
||||||
// Load index
|
|
||||||
if !mb.indexLoaded {
|
|
||||||
if err := mb.readIndex(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old messages over messageCap
|
|
||||||
if mb.store.messageCap > 0 {
|
|
||||||
for len(mb.messages) >= mb.store.messageCap {
|
|
||||||
log.Infof("Mailbox %q over configured message cap", mb.name)
|
|
||||||
if err := mb.messages[0].Delete(); err != nil {
|
|
||||||
log.Errorf("Error deleting message: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
date := time.Now()
|
|
||||||
id := generateID(date)
|
|
||||||
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ID gets the ID of the Message
|
|
||||||
func (m *FileMessage) ID() string {
|
|
||||||
return m.Fid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date returns the date/time this Message was received by Inbucket
|
|
||||||
func (m *FileMessage) Date() time.Time {
|
|
||||||
return m.Fdate
|
|
||||||
}
|
|
||||||
|
|
||||||
// From returns the value of the Message From header
|
|
||||||
func (m *FileMessage) From() string {
|
|
||||||
return m.Ffrom
|
|
||||||
}
|
|
||||||
|
|
||||||
// To returns the value of the Message To header
|
|
||||||
func (m *FileMessage) To() []string {
|
|
||||||
return m.Fto
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject returns the value of the Message Subject header
|
|
||||||
func (m *FileMessage) Subject() string {
|
|
||||||
return m.Fsubject
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns a string in the form: "Subject()" from From()
|
|
||||||
func (m *FileMessage) String() string {
|
|
||||||
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size returns the size of the Message on disk in bytes
|
|
||||||
func (m *FileMessage) Size() int64 {
|
|
||||||
return m.Fsize
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *FileMessage) rawPath() string {
|
|
||||||
return filepath.Join(m.mailbox.path, m.Fid+".raw")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
|
|
||||||
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
|
|
||||||
file, err := os.Open(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
return mail.ReadMessage(reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
|
|
||||||
func (m *FileMessage) ReadBody() (body *enmime.Envelope, err error) {
|
|
||||||
file, err := os.Open(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
mime, err := enmime.ReadEnvelope(reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return mime, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RawReader opens the .raw portion of a Message as an io.ReadCloser
|
|
||||||
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
|
|
||||||
file, err := os.Open(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadRaw opens the .raw portion of a Message and returns it as a string
|
|
||||||
func (m *FileMessage) ReadRaw() (raw *string, err error) {
|
|
||||||
reader, err := m.RawReader()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := reader.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
bodyString := string(bodyBytes)
|
|
||||||
return &bodyString, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append data to a newly opened Message, this will fail on a pre-existing Message and
|
|
||||||
// after Close() is called.
|
|
||||||
func (m *FileMessage) Append(data []byte) error {
|
|
||||||
// Prevent Appending to a pre-existing Message
|
|
||||||
if !m.writable {
|
|
||||||
return datastore.ErrNotWritable
|
|
||||||
}
|
|
||||||
// Open file for writing if we haven't yet
|
|
||||||
if m.writer == nil {
|
|
||||||
// Ensure mailbox directory exists
|
|
||||||
if err := m.mailbox.createDir(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
file, err := os.Create(m.rawPath())
|
|
||||||
if err != nil {
|
|
||||||
// Set writable false just in case something calls me a million times
|
|
||||||
m.writable = false
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.writerFile = file
|
|
||||||
m.writer = bufio.NewWriter(file)
|
|
||||||
}
|
|
||||||
_, err := m.writer.Write(data)
|
|
||||||
m.Fsize += int64(len(data))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close this Message for writing - no more data may be Appended. Close() will also
|
|
||||||
// trigger the creation of the .gob file.
|
|
||||||
func (m *FileMessage) Close() error {
|
|
||||||
// nil out the writer fields so they can't be used
|
|
||||||
writer := m.writer
|
|
||||||
writerFile := m.writerFile
|
|
||||||
m.writer = nil
|
|
||||||
m.writerFile = nil
|
|
||||||
|
|
||||||
if writer != nil {
|
|
||||||
if err := writer.Flush(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if writerFile != nil {
|
|
||||||
if err := writerFile.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch headers
|
|
||||||
body, err := m.ReadBody()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only public fields are stored in gob, hence starting with capital F
|
|
||||||
// Parse From address
|
|
||||||
if address, err := mail.ParseAddress(body.GetHeader("From")); err == nil {
|
|
||||||
m.Ffrom = address.String()
|
|
||||||
} else {
|
|
||||||
m.Ffrom = body.GetHeader("From")
|
|
||||||
}
|
|
||||||
m.Fsubject = body.GetHeader("Subject")
|
|
||||||
|
|
||||||
// Turn the To header into a slice
|
|
||||||
if addresses, err := body.AddressList("To"); err == nil {
|
|
||||||
for _, a := range addresses {
|
|
||||||
m.Fto = append(m.Fto, a.String())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.Fto = []string{body.GetHeader("To")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the index before adding our message
|
|
||||||
err = m.mailbox.readIndex()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Made it this far without errors, add it to the index
|
|
||||||
m.mailbox.messages = append(m.mailbox.messages, m)
|
|
||||||
return m.mailbox.writeIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete this Message from disk by removing it from the index and deleting the
|
|
||||||
// raw files.
|
|
||||||
func (m *FileMessage) Delete() error {
|
|
||||||
messages := m.mailbox.messages
|
|
||||||
for i, mm := range messages {
|
|
||||||
if m == mm {
|
|
||||||
// Slice around message we are deleting
|
|
||||||
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := m.mailbox.writeIndex(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(m.mailbox.messages) == 0 {
|
|
||||||
// This was the last message, thus writeIndex() has removed the entire
|
|
||||||
// directory; we don't need to delete the raw file.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// There are still messages in the index
|
|
||||||
log.Tracef("Deleting %v", m.rawPath())
|
|
||||||
return os.Remove(m.rawPath())
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package filestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/jhillyerd/inbucket/datastore"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
"github.com/jhillyerd/inbucket/stringutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Name of index file in each mailbox
|
|
||||||
const indexFileName = "index.gob"
|
|
||||||
|
|
||||||
var (
|
|
||||||
// indexMx is locked while reading/writing an index file
|
|
||||||
//
|
|
||||||
// NOTE: This is a bottleneck because it's a single lock even if we have a
|
|
||||||
// million index files
|
|
||||||
indexMx = new(sync.RWMutex)
|
|
||||||
|
|
||||||
// dirMx is locked while creating/removing directories
|
|
||||||
dirMx = new(sync.Mutex)
|
|
||||||
|
|
||||||
// countChannel is filled with a sequential numbers (0000..9999), which are
|
|
||||||
// used by generateID() to generate unique message IDs. It's global
|
|
||||||
// because we only want one regardless of the number of DataStore objects
|
|
||||||
countChannel = make(chan int, 10)
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Start generator
|
|
||||||
go countGenerator(countChannel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populates the channel with numbers
|
|
||||||
func countGenerator(c chan int) {
|
|
||||||
for i := 0; true; i = (i + 1) % 10000 {
|
|
||||||
c <- i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileDataStore implements DataStore aand is the root of the mail storage
|
|
||||||
// hiearchy. It provides access to Mailbox objects
|
|
||||||
type FileDataStore struct {
|
|
||||||
hashLock datastore.HashLock
|
|
||||||
path string
|
|
||||||
mailPath string
|
|
||||||
messageCap int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileDataStore creates a new DataStore object using the specified path
|
|
||||||
func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore {
|
|
||||||
path := cfg.Path
|
|
||||||
if path == "" {
|
|
||||||
log.Errorf("No value configured for datastore path")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
mailPath := filepath.Join(path, "mail")
|
|
||||||
if _, err := os.Stat(mailPath); err != nil {
|
|
||||||
// Mail datastore does not yet exist
|
|
||||||
if err = os.MkdirAll(mailPath, 0770); err != nil {
|
|
||||||
log.Errorf("Error creating dir %q: %v", mailPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &FileDataStore{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
|
|
||||||
// construct it's path.
|
|
||||||
func DefaultFileDataStore() datastore.DataStore {
|
|
||||||
cfg := config.GetDataStoreConfig()
|
|
||||||
return NewFileDataStore(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
|
|
||||||
// does not exist, it will attempt to create it.
|
|
||||||
func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
|
|
||||||
name, err := stringutil.ParseMailboxName(emailAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dir := stringutil.HashMailboxName(name)
|
|
||||||
s1 := dir[0:3]
|
|
||||||
s2 := dir[0:6]
|
|
||||||
path := filepath.Join(ds.mailPath, s1, s2, dir)
|
|
||||||
indexPath := filepath.Join(path, indexFileName)
|
|
||||||
|
|
||||||
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
|
|
||||||
indexPath: indexPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllMailboxes returns a slice with all Mailboxes
|
|
||||||
func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
|
|
||||||
mailboxes := make([]datastore.Mailbox, 0, 100)
|
|
||||||
infos1, err := ioutil.ReadDir(ds.mailPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Loop over level 1 directories
|
|
||||||
for _, inf1 := range infos1 {
|
|
||||||
if inf1.IsDir() {
|
|
||||||
l1 := inf1.Name()
|
|
||||||
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Loop over level 2 directories
|
|
||||||
for _, inf2 := range infos2 {
|
|
||||||
if inf2.IsDir() {
|
|
||||||
l2 := inf2.Name()
|
|
||||||
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Loop over mailboxes
|
|
||||||
for _, inf3 := range infos3 {
|
|
||||||
if inf3.IsDir() {
|
|
||||||
mbdir := inf3.Name()
|
|
||||||
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
|
|
||||||
idx := filepath.Join(mbpath, indexFileName)
|
|
||||||
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
|
|
||||||
indexPath: idx}
|
|
||||||
mailboxes = append(mailboxes, mb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mailboxes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ds *FileDataStore) LockFor(emailAddress string) (*sync.RWMutex, error) {
|
|
||||||
name, err := stringutil.ParseMailboxName(emailAddress)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
hash := stringutil.HashMailboxName(name)
|
|
||||||
return ds.hashLock.Get(hash), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileMailbox implements Mailbox, manages the mail for a specific user and
|
|
||||||
// correlates to a particular directory on disk.
|
|
||||||
type FileMailbox struct {
|
|
||||||
store *FileDataStore
|
|
||||||
name string
|
|
||||||
dirName string
|
|
||||||
path string
|
|
||||||
indexLoaded bool
|
|
||||||
indexPath string
|
|
||||||
messages []*FileMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name of the mailbox
|
|
||||||
func (mb *FileMailbox) Name() string {
|
|
||||||
return mb.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// String renders the name and directory path of the mailbox
|
|
||||||
func (mb *FileMailbox) String() string {
|
|
||||||
return mb.name + "[" + mb.dirName + "]"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages scans the mailbox directory for .gob files and decodes them into
|
|
||||||
// a slice of Message objects.
|
|
||||||
func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
|
|
||||||
if !mb.indexLoaded {
|
|
||||||
if err := mb.readIndex(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := make([]datastore.Message, len(mb.messages))
|
|
||||||
for i, m := range mb.messages {
|
|
||||||
messages[i] = m
|
|
||||||
}
|
|
||||||
return messages, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessage decodes a single message by Id and returns a Message object
|
|
||||||
func (mb *FileMailbox) GetMessage(id string) (datastore.Message, error) {
|
|
||||||
if !mb.indexLoaded {
|
|
||||||
if err := mb.readIndex(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if id == "latest" && len(mb.messages) != 0 {
|
|
||||||
return mb.messages[len(mb.messages)-1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range mb.messages {
|
|
||||||
if m.Fid == id {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, datastore.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge deletes all messages in this mailbox
|
|
||||||
func (mb *FileMailbox) Purge() error {
|
|
||||||
mb.messages = mb.messages[:0]
|
|
||||||
return mb.writeIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
// readIndex loads the mailbox index data from disk
|
|
||||||
func (mb *FileMailbox) readIndex() error {
|
|
||||||
// Clear message slice, open index
|
|
||||||
mb.messages = mb.messages[:0]
|
|
||||||
// Lock for reading
|
|
||||||
indexMx.RLock()
|
|
||||||
defer indexMx.RUnlock()
|
|
||||||
// Check if index exists
|
|
||||||
if _, err := os.Stat(mb.indexPath); err != nil {
|
|
||||||
// Does not exist, but that's not an error in our world
|
|
||||||
log.Tracef("Index %v does not exist (yet)", mb.indexPath)
|
|
||||||
mb.indexLoaded = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
file, err := os.Open(mb.indexPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Decode gob data
|
|
||||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
|
||||||
for {
|
|
||||||
msg := new(FileMessage)
|
|
||||||
if err = dec.Decode(msg); err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
// It's OK to get an EOF here
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
|
||||||
}
|
|
||||||
msg.mailbox = mb
|
|
||||||
mb.messages = append(mb.messages, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
mb.indexLoaded = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeIndex overwrites the index on disk with the current mailbox data
|
|
||||||
func (mb *FileMailbox) writeIndex() error {
|
|
||||||
// Lock for writing
|
|
||||||
indexMx.Lock()
|
|
||||||
defer indexMx.Unlock()
|
|
||||||
if len(mb.messages) > 0 {
|
|
||||||
// Ensure mailbox directory exists
|
|
||||||
if err := mb.createDir(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Open index for writing
|
|
||||||
file, err := os.Create(mb.indexPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
writer := bufio.NewWriter(file)
|
|
||||||
// Write each message and then flush
|
|
||||||
enc := gob.NewEncoder(writer)
|
|
||||||
for _, m := range mb.messages {
|
|
||||||
err = enc.Encode(m)
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := writer.Flush(); err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No messages, delete index+maildir
|
|
||||||
log.Tracef("Removing mailbox %v", mb.path)
|
|
||||||
return mb.removeDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createDir checks for the presence of the path for this mailbox, creates it if needed
|
|
||||||
func (mb *FileMailbox) createDir() error {
|
|
||||||
dirMx.Lock()
|
|
||||||
defer dirMx.Unlock()
|
|
||||||
if _, err := os.Stat(mb.path); err != nil {
|
|
||||||
if err := os.MkdirAll(mb.path, 0770); err != nil {
|
|
||||||
log.Errorf("Failed to create directory %v, %v", mb.path, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDir removes the mailbox, plus empty higher level directories
|
|
||||||
func (mb *FileMailbox) removeDir() error {
|
|
||||||
dirMx.Lock()
|
|
||||||
defer dirMx.Unlock()
|
|
||||||
// remove mailbox dir, including index file
|
|
||||||
if err := os.RemoveAll(mb.path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// remove parents if empty
|
|
||||||
dir := filepath.Dir(mb.path)
|
|
||||||
if removeDirIfEmpty(dir) {
|
|
||||||
removeDirIfEmpty(filepath.Dir(dir))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
|
|
||||||
// Caller should hold dirMx. Returns true if dir was removed.
|
|
||||||
func removeDirIfEmpty(path string) (removed bool) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
files, err := f.Readdirnames(0)
|
|
||||||
_ = f.Close()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(files) > 0 {
|
|
||||||
// Dir not empty
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
log.Tracef("Removing dir %v", path)
|
|
||||||
err = os.Remove(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Failed to remove %q: %v", path, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatePrefix converts a Time object into the ISO style format we use
|
|
||||||
// as a prefix for message files. Note: It is used directly by unit
|
|
||||||
// tests.
|
|
||||||
func generatePrefix(date time.Time) string {
|
|
||||||
return date.Format("20060102T150405")
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateId adds a 4-digit unique number onto the end of the string
|
|
||||||
// returned by generatePrefix()
|
|
||||||
func generateID(date time.Time) string {
|
|
||||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
|
||||||
}
|
|
||||||
@@ -1,583 +0,0 @@
|
|||||||
package filestore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test directory structure created by filestore
|
|
||||||
func TestFSDirStructure(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
root := ds.path
|
|
||||||
|
|
||||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
|
||||||
mbName := "james"
|
|
||||||
|
|
||||||
// Check filestore root exists
|
|
||||||
assert.True(t, isDir(root), "Expected %q to be a directory", root)
|
|
||||||
|
|
||||||
// Check mail dir exists
|
|
||||||
expect := filepath.Join(root, "mail")
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
|
|
||||||
// Check first hash section does not exist
|
|
||||||
expect = filepath.Join(root, "mail", "474")
|
|
||||||
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
|
|
||||||
|
|
||||||
// Deliver test message
|
|
||||||
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
|
|
||||||
|
|
||||||
// Check path to message exists
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
expect = filepath.Join(expect, "474ba6")
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
|
|
||||||
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
|
|
||||||
|
|
||||||
// Check files
|
|
||||||
mbPath := expect
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
expect = filepath.Join(mbPath, id1+".raw")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
|
|
||||||
// Deliver second test message
|
|
||||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
|
||||||
|
|
||||||
// Check files
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
expect = filepath.Join(mbPath, id2+".raw")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
|
|
||||||
// Delete message
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err := mb.GetMessage(id1)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
err = msg.Delete()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Message should be removed
|
|
||||||
expect = filepath.Join(mbPath, id1+".raw")
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
|
|
||||||
|
|
||||||
// Delete message
|
|
||||||
msg, err = mb.GetMessage(id2)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
err = msg.Delete()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Message should be removed
|
|
||||||
expect = filepath.Join(mbPath, id2+".raw")
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
|
|
||||||
// No messages, index & maildir should be removed
|
|
||||||
expect = filepath.Join(mbPath, "index.gob")
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
expect = mbPath
|
|
||||||
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
|
|
||||||
|
|
||||||
if t.Failed() {
|
|
||||||
// Wait for handler to finish logging
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
// Dump buffered log data if there was a failure
|
|
||||||
_, _ = io.Copy(os.Stderr, logbuf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test FileDataStore.AllMailboxes()
|
|
||||||
func TestFSAllMailboxes(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
|
|
||||||
// Create day old message
|
|
||||||
date := time.Now().Add(-24 * time.Hour)
|
|
||||||
deliverMessage(ds, name, "Old Message", date)
|
|
||||||
|
|
||||||
// Create current message
|
|
||||||
date = time.Now()
|
|
||||||
deliverMessage(ds, name, "New Message", date)
|
|
||||||
}
|
|
||||||
|
|
||||||
mboxes, err := ds.AllMailboxes()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, len(mboxes), 5)
|
|
||||||
|
|
||||||
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 delivering several messages to the same mailbox, meanwhile querying its
|
|
||||||
// contents with a new mailbox object each time
|
|
||||||
func TestFSDeliverMany(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
|
||||||
|
|
||||||
for i, subj := range subjects {
|
|
||||||
// Check number of messages
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, i, len(msgs), "Expected %v message(s), but got %v", i, len(msgs))
|
|
||||||
|
|
||||||
// Add a message
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
|
|
||||||
// Confirm delivery order
|
|
||||||
for i, expect := range subjects {
|
|
||||||
subj := msgs[i].Subject()
|
|
||||||
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 deleting messages
|
|
||||||
func TestFSDelete(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
|
||||||
|
|
||||||
for _, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
|
|
||||||
// Delete a couple messages
|
|
||||||
_ = msgs[1].Delete()
|
|
||||||
_ = msgs[3].Delete()
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err = mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subjects = []string{"alpha", "charlie", "echo"}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
for i, expect := range subjects {
|
|
||||||
subj := msgs[i].Subject()
|
|
||||||
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try appending one more
|
|
||||||
deliverMessage(ds, mbName, "foxtrot", time.Now())
|
|
||||||
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err = mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
for i, expect := range subjects {
|
|
||||||
subj := msgs[i].Subject()
|
|
||||||
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 purging a mailbox
|
|
||||||
func TestFSPurge(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
|
||||||
|
|
||||||
for _, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
|
|
||||||
len(subjects), len(msgs))
|
|
||||||
|
|
||||||
// Purge mailbox
|
|
||||||
err = mb.Purge()
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err = mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, len(msgs), 0, "Expected mailbox to have zero messages, got %v", len(msgs))
|
|
||||||
|
|
||||||
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 message size calculation
|
|
||||||
func TestFSSize(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"a", "br", "much longer than the others"}
|
|
||||||
sentIds := make([]string, len(subjects))
|
|
||||||
sentSizes := make([]int64, len(subjects))
|
|
||||||
|
|
||||||
for i, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
id, size := deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
sentIds[i] = id
|
|
||||||
sentSizes[i] = size
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
for i, id := range sentIds {
|
|
||||||
msg, err := mb.GetMessage(id)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
expect := sentSizes[i]
|
|
||||||
size := msg.Size()
|
|
||||||
assert.Equal(t, expect, size, "Expected size of %v, got %v", expect, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.Failed() {
|
|
||||||
// Wait for handler to finish logging
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
// Dump buffered log data if there was a failure
|
|
||||||
_, _ = io.Copy(os.Stderr, logbuf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test missing files
|
|
||||||
func TestFSMissing(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "fred"
|
|
||||||
subjects := []string{"a", "b", "c"}
|
|
||||||
sentIds := make([]string, len(subjects))
|
|
||||||
|
|
||||||
for i, subj := range subjects {
|
|
||||||
// Add a message
|
|
||||||
id, _ := deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
sentIds[i] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete a message file without removing it from index
|
|
||||||
msg, err := mb.GetMessage(sentIds[1])
|
|
||||||
assert.Nil(t, err)
|
|
||||||
fmsg := msg.(*FileMessage)
|
|
||||||
_ = os.Remove(fmsg.rawPath())
|
|
||||||
msg, err = mb.GetMessage(sentIds[1])
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
// Try to read parts of message
|
|
||||||
_, err = msg.ReadHeader()
|
|
||||||
assert.Error(t, err)
|
|
||||||
_, err = msg.ReadBody()
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
if t.Failed() {
|
|
||||||
// Wait for handler to finish logging
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
// Dump buffered log data if there was a failure
|
|
||||||
_, _ = io.Copy(os.Stderr, logbuf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test delivering several messages to the same mailbox, see if message cap works
|
|
||||||
func TestFSMessageCap(t *testing.T) {
|
|
||||||
mbCap := 10
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "captain"
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
// Add a message
|
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
t.Logf("Delivered %q", subj)
|
|
||||||
|
|
||||||
// Check number of messages
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
if len(msgs) > mbCap {
|
|
||||||
t.Errorf("Mailbox should be capped at %v messages, but has %v", mbCap, len(msgs))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that the first message is correct
|
|
||||||
first := i - mbCap + 1
|
|
||||||
if first < 0 {
|
|
||||||
first = 0
|
|
||||||
}
|
|
||||||
firstSubj := fmt.Sprintf("subject %v", first)
|
|
||||||
if firstSubj != msgs[0].Subject() {
|
|
||||||
t.Errorf("Expected first subject to be %q, got %q", firstSubj, msgs[0].Subject())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 delivering several messages to the same mailbox, see if no message cap works
|
|
||||||
func TestFSNoMessageCap(t *testing.T) {
|
|
||||||
mbCap := 0
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
mbName := "captain"
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
// Add a message
|
|
||||||
subj := fmt.Sprintf("subject %v", i)
|
|
||||||
deliverMessage(ds, mbName, subj, time.Now())
|
|
||||||
t.Logf("Delivered %q", subj)
|
|
||||||
|
|
||||||
// Check number of messages
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
|
||||||
}
|
|
||||||
msgs, err := mb.GetMessages()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
|
||||||
}
|
|
||||||
if len(msgs) != i+1 {
|
|
||||||
t.Errorf("Expected %v messages, got %v", i+1, len(msgs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.Failed() {
|
|
||||||
// Wait for handler to finish logging
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
// Dump buffered log data if there was a failure
|
|
||||||
_, _ = io.Copy(os.Stderr, logbuf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Get the latest message
|
|
||||||
func TestGetLatestMessage(t *testing.T) {
|
|
||||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
|
||||||
defer teardownDataStore(ds)
|
|
||||||
|
|
||||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
|
||||||
mbName := "james"
|
|
||||||
|
|
||||||
// Test empty mailbox
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err := mb.GetMessage("latest")
|
|
||||||
assert.Nil(t, msg)
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
// Deliver test message
|
|
||||||
deliverMessage(ds, mbName, "test", time.Now())
|
|
||||||
|
|
||||||
// Deliver test message 2
|
|
||||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
|
||||||
|
|
||||||
// Test get the latest message
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err = mb.GetMessage("latest")
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
|
|
||||||
|
|
||||||
// Deliver test message 3
|
|
||||||
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
|
|
||||||
|
|
||||||
mb, err = ds.MailboxFor(mbName)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
msg, err = mb.GetMessage("latest")
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
|
|
||||||
|
|
||||||
// Test wrong id
|
|
||||||
_, err = mb.GetMessage("wrongid")
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
if t.Failed() {
|
|
||||||
// Wait for handler to finish logging
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
// Dump buffered log data if there was a failure
|
|
||||||
_, _ = io.Copy(os.Stderr, logbuf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupDataStore creates a new FileDataStore in a temporary directory
|
|
||||||
func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
|
|
||||||
path, err := ioutil.TempDir("", "inbucket")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture log output
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
log.SetOutput(buf)
|
|
||||||
|
|
||||||
cfg.Path = path
|
|
||||||
return NewFileDataStore(cfg).(*FileDataStore), buf
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverMessage creates and delivers a message to the specific mailbox, returning
|
|
||||||
// the size of the generated message.
|
|
||||||
func deliverMessage(ds *FileDataStore, mbName string, subject string,
|
|
||||||
date time.Time) (id string, size int64) {
|
|
||||||
// Build fake SMTP message for delivery
|
|
||||||
testMsg := make([]byte, 0, 300)
|
|
||||||
testMsg = append(testMsg, []byte("To: somebody@host\r\n")...)
|
|
||||||
testMsg = append(testMsg, []byte("From: somebodyelse@host\r\n")...)
|
|
||||||
testMsg = append(testMsg, []byte(fmt.Sprintf("Subject: %s\r\n", subject))...)
|
|
||||||
testMsg = append(testMsg, []byte("\r\n")...)
|
|
||||||
testMsg = append(testMsg, []byte("Test Body\r\n")...)
|
|
||||||
|
|
||||||
mb, err := ds.MailboxFor(mbName)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
// Create message object
|
|
||||||
id = generateID(date)
|
|
||||||
msg, err := mb.NewMessage()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmsg := msg.(*FileMessage)
|
|
||||||
fmsg.Fdate = date
|
|
||||||
fmsg.Fid = id
|
|
||||||
if err = msg.Append(testMsg); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err = msg.Close(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, int64(len(testMsg))
|
|
||||||
}
|
|
||||||
|
|
||||||
func teardownDataStore(ds *FileDataStore) {
|
|
||||||
if err := os.RemoveAll(ds.path); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPresent(path string) bool {
|
|
||||||
_, err := os.Lstat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFile(path string) bool {
|
|
||||||
if fi, err := os.Lstat(path); err == nil {
|
|
||||||
return !fi.IsDir()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDir(path string) bool {
|
|
||||||
if fi, err := os.Lstat(path); err == nil {
|
|
||||||
return fi.IsDir()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module github.com/inbucket/inbucket/v3
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
toolchain go1.21.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cjoudrey/gluahttp v0.0.0-20201111170219-25003d9adfa9
|
||||||
|
github.com/cosmotek/loguago v1.0.0
|
||||||
|
github.com/google/subcommands v1.2.0
|
||||||
|
github.com/gorilla/css v1.0.1
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/gorilla/websocket v1.5.1
|
||||||
|
github.com/inbucket/gopher-json v0.2.0
|
||||||
|
github.com/jhillyerd/enmime v1.1.0
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26
|
||||||
|
github.com/rs/zerolog v1.32.0
|
||||||
|
github.com/stretchr/testify v1.8.4
|
||||||
|
github.com/yuin/gopher-lua v1.1.1
|
||||||
|
golang.org/x/net v0.20.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/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // 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.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // 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.6 // indirect
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||||
|
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 // indirect
|
||||||
|
golang.org/x/sys v0.16.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cosmotek/loguago v1.0.0 h1:cM6xoMPoIL1hRPicMenFNVohylundRIPz+OfpadJyY0=
|
||||||
|
github.com/cosmotek/loguago v1.0.0/go.mod h1:M/3wRiTLODLY6ufA9sVxOgSvnkYv53sYuDTQEqX0lZ4=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
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.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-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.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
|
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-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/jhillyerd/enmime v1.1.0 h1:ubaIzg68VY7CMCe2YbHe6nkRvU9vujixTkNz3EBvZOw=
|
||||||
|
github.com/jhillyerd/enmime v1.1.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
|
||||||
|
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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||||
|
github.com/rivo/uniseg v0.4.6/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.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
||||||
|
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
|
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/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 h1:noHsffKZsNfU38DwcXWEPldrTjIZ8FPNKx8mYMGnqjs=
|
||||||
|
github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7/go.mod h1:bbMEM6aU1WDF1ErA5YJ0p91652pGv140gGw4Ww3RGp8=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
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.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
@@ -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/datastore"
|
|
||||||
"github.com/jhillyerd/inbucket/log"
|
|
||||||
"github.com/jhillyerd/inbucket/msghub"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 datastore.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 datastore.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
@@ -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/filestore"
|
|
||||||
"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
|
|
||||||
version = "undefined"
|
|
||||||
|
|
||||||
// date contains the build date, populated during linking
|
|
||||||
date = "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 = date
|
|
||||||
|
|
||||||
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, 1)
|
|
||||||
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 := filestore.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
|
|
||||||
pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
|
|
||||||
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
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/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"`
|
||||||
|
RejectOriginDomains []string `desc:"Domains to reject mail from"`
|
||||||
|
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"`
|
||||||
|
ForceTLS bool `default:"false" desc:"Listen for connections with TLS."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
TLSEnabled bool `default:"false" desc:"Enable TLS"`
|
||||||
|
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"`
|
||||||
|
ForceTLS bool `default:"false" desc:"If true, TLS is always on. If false, enable STLS"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package extension_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/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")
|
||||||
|
}
|
||||||
@@ -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 interface{}] 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package extension_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddressParts contains the local and domain parts of an email address.
|
||||||
|
type AddressParts struct {
|
||||||
|
Local string
|
||||||
|
Domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboundMessage contains the basic header and mailbox data for a message being received.
|
||||||
|
type InboundMessage struct {
|
||||||
|
Mailboxes []string
|
||||||
|
From *mail.Address
|
||||||
|
To []*mail.Address
|
||||||
|
Subject string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/inbucket/inbucket/v3/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]
|
||||||
|
BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inboundMessageName = "inbound_message"
|
||||||
|
|
||||||
|
func registerInboundMessageType(ls *lua.LState) {
|
||||||
|
mt := ls.NewTypeMetatable(inboundMessageName)
|
||||||
|
ls.SetGlobal(inboundMessageName, mt)
|
||||||
|
|
||||||
|
// Static attributes.
|
||||||
|
ls.SetField(mt, "new", ls.NewFunction(newInboundMessage))
|
||||||
|
|
||||||
|
// Methods.
|
||||||
|
ls.SetField(mt, "__index", ls.NewFunction(inboundMessageIndex))
|
||||||
|
ls.SetField(mt, "__newindex", ls.NewFunction(inboundMessageNewIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInboundMessage(ls *lua.LState) int {
|
||||||
|
val := &event.InboundMessage{}
|
||||||
|
ud := wrapInboundMessage(ls, val)
|
||||||
|
ls.Push(ud)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInboundMessage(ls *lua.LState, val *event.InboundMessage) *lua.LUserData {
|
||||||
|
ud := ls.NewUserData()
|
||||||
|
ud.Value = val
|
||||||
|
ls.SetMetatable(ud, ls.GetTypeMetatable(inboundMessageName))
|
||||||
|
|
||||||
|
return ud
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks there is an InboundMessage at stack position `pos`, else throws Lua error.
|
||||||
|
func checkInboundMessage(ls *lua.LState, pos int) *event.InboundMessage {
|
||||||
|
ud := ls.CheckUserData(pos)
|
||||||
|
if v, ok := ud.Value.(*event.InboundMessage); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
ls.ArgError(pos, inboundMessageName+" expected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapInboundMessage(lv lua.LValue) (*event.InboundMessage, error) {
|
||||||
|
if ud, ok := lv.(*lua.LUserData); ok {
|
||||||
|
if v, ok := ud.Value.(*event.InboundMessage); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("Expected InboundMessage, got %q", lv.Type().String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gets a field value from InboundMessage user object. This emulates a Lua table,
|
||||||
|
// allowing `msg.subject` instead of a Lua object syntax of `msg:subject()`.
|
||||||
|
func inboundMessageIndex(ls *lua.LState) int {
|
||||||
|
m := checkInboundMessage(ls, 1)
|
||||||
|
field := ls.CheckString(2)
|
||||||
|
|
||||||
|
// Push the requested field's value onto the stack.
|
||||||
|
switch field {
|
||||||
|
case "mailboxes":
|
||||||
|
lt := &lua.LTable{}
|
||||||
|
for _, v := range m.Mailboxes {
|
||||||
|
lt.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
ls.Push(lt)
|
||||||
|
case "from":
|
||||||
|
ls.Push(wrapMailAddress(ls, m.From))
|
||||||
|
case "to":
|
||||||
|
lt := &lua.LTable{}
|
||||||
|
for _, v := range m.To {
|
||||||
|
addr := v
|
||||||
|
lt.Append(wrapMailAddress(ls, addr))
|
||||||
|
}
|
||||||
|
ls.Push(lt)
|
||||||
|
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 InboundMessage user object. This emulates a Lua table,
|
||||||
|
// allowing `msg.subject = x` instead of a Lua object syntax of `msg:subject(x)`.
|
||||||
|
func inboundMessageNewIndex(ls *lua.LState) int {
|
||||||
|
m := checkInboundMessage(ls, 1)
|
||||||
|
index := ls.CheckString(2)
|
||||||
|
|
||||||
|
switch index {
|
||||||
|
case "mailboxes":
|
||||||
|
lt := ls.CheckTable(3)
|
||||||
|
mailboxes := make([]string, 0, 16)
|
||||||
|
lt.ForEach(func(k, lv lua.LValue) {
|
||||||
|
if mb, ok := lv.(lua.LString); ok {
|
||||||
|
mailboxes = append(mailboxes, string(mb))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.Mailboxes = mailboxes
|
||||||
|
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 {
|
||||||
|
// TODO should fail if wrong type + test.
|
||||||
|
if entry, ok := unwrapMailAddress(ud); ok {
|
||||||
|
to = append(to, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.To = to
|
||||||
|
case "subject":
|
||||||
|
m.Subject = ls.CheckString(3)
|
||||||
|
case "size":
|
||||||
|
ls.RaiseError("size is read-only")
|
||||||
|
default:
|
||||||
|
ls.RaiseError("invalid index %q", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LuaInit holds useful test globals.
|
||||||
|
const LuaInit = `
|
||||||
|
function assert_eq(got, want)
|
||||||
|
if type(got) == "table" and type(want) == "table" then
|
||||||
|
assert(#got == #want, string.format("got %d element(s), 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(got == want, string.format("got %q, wanted %q", got, want))
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestInboundMessageGetters(t *testing.T) {
|
||||||
|
want := &event.InboundMessage{
|
||||||
|
Mailboxes: []string{"mb1", "mb2"},
|
||||||
|
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||||
|
To: []*mail.Address{
|
||||||
|
{Name: "name2", Address: "addr2"},
|
||||||
|
{Name: "name3", Address: "addr3"},
|
||||||
|
},
|
||||||
|
Subject: "subj1",
|
||||||
|
Size: 42,
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(msg, "msg should not be nil")
|
||||||
|
|
||||||
|
assert_eq(msg.mailboxes, {"mb1", "mb2"})
|
||||||
|
assert_eq(msg.subject, "subj1")
|
||||||
|
assert_eq(msg.size, 42)
|
||||||
|
|
||||||
|
assert_eq(msg.from.name, "name1")
|
||||||
|
assert_eq(msg.from.address, "addr1")
|
||||||
|
|
||||||
|
assert_eq(#msg.to, 2)
|
||||||
|
assert_eq(msg.to[1].name, "name2")
|
||||||
|
assert_eq(msg.to[1].address, "addr2")
|
||||||
|
assert_eq(msg.to[2].name, "name3")
|
||||||
|
assert_eq(msg.to[2].address, "addr3")
|
||||||
|
`
|
||||||
|
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerInboundMessageType(ls)
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
ls.SetGlobal("msg", wrapInboundMessage(ls, want))
|
||||||
|
require.NoError(t, ls.DoString(LuaInit+script))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundMessageSetters(t *testing.T) {
|
||||||
|
want := &event.InboundMessage{
|
||||||
|
Mailboxes: []string{"mb1", "mb2"},
|
||||||
|
From: &mail.Address{Name: "name1", Address: "addr1"},
|
||||||
|
To: []*mail.Address{
|
||||||
|
{Name: "name2", Address: "addr2"},
|
||||||
|
{Name: "name3", Address: "addr3"},
|
||||||
|
},
|
||||||
|
Subject: "subj1",
|
||||||
|
}
|
||||||
|
script := `
|
||||||
|
assert(msg, "msg should not be nil")
|
||||||
|
|
||||||
|
msg.mailboxes = {"mb1", "mb2"}
|
||||||
|
msg.subject = "subj1"
|
||||||
|
msg.from = address.new("name1", "addr1")
|
||||||
|
msg.to = { address.new("name2", "addr2"), address.new("name3", "addr3") }
|
||||||
|
`
|
||||||
|
|
||||||
|
got := &event.InboundMessage{}
|
||||||
|
ls := lua.NewState()
|
||||||
|
registerInboundMessageType(ls)
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
ls.SetGlobal("msg", wrapInboundMessage(ls, got))
|
||||||
|
require.NoError(t, ls.DoString(script))
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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
|
||||||
|
MessageStored *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))
|
||||||
|
case "message_stored":
|
||||||
|
ls.Push(funcOrNil(before.MessageStored))
|
||||||
|
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)
|
||||||
|
case "message_stored":
|
||||||
|
m.MessageStored = 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
|
||||||
|
}
|
||||||
@@ -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", "message_stored" }
|
||||||
|
|
||||||
|
-- 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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/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 {
|
||||||
|
// TODO should fail if wrong type + test.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/v3/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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
return NewFromReader(logContext.Logger(), 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(logger zerolog.Logger, extHost *extension.Host, r io.Reader, path string) (*Host, error) {
|
||||||
|
startLogger := logger.With().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(logger, proto)
|
||||||
|
h := &Host{extHost: extHost, pool: pool, logContext: logger.With()}
|
||||||
|
if ls, err := pool.getState(); err == nil {
|
||||||
|
h.wireFunctions(startLogger, 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)
|
||||||
|
}
|
||||||
|
if ib.Before.MessageStored != nil {
|
||||||
|
events.BeforeMessageStored.AddListener(listenerName, h.handleBeforeMessageStored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("before.mail_accepted")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) handleBeforeMessageStored(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
logger, ls, ib, ok := h.prepareInbucketFuncCall("before.message_stored")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer h.pool.putState(ls)
|
||||||
|
|
||||||
|
logger.Debug().Msgf("Calling Lua function with %+v", msg)
|
||||||
|
if err := ls.CallByParam(
|
||||||
|
lua.P{Fn: ib.Before.MessageStored, NRet: 1, Protect: true},
|
||||||
|
wrapInboundMessage(ls, &msg),
|
||||||
|
); err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Failed to call Lua function")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lval := ls.Get(1)
|
||||||
|
logger.Debug().Msgf("Lua function returned %q (%v)", lval, lval.Type().String())
|
||||||
|
|
||||||
|
if lval.Type() == lua.LTNil || lua.LVIsFalse(lval) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := unwrapInboundMessage(lval)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Bad response from Lua Function")
|
||||||
|
}
|
||||||
|
ls.Pop(1)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package luahost_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/luahost"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LuaInit holds useful test globals.
|
||||||
|
const LuaInit = `
|
||||||
|
local logger = require("logger")
|
||||||
|
|
||||||
|
async = false
|
||||||
|
test_ok = true
|
||||||
|
|
||||||
|
-- Sends marks tests as failed instead of erroring when enabled.
|
||||||
|
function assert_async(value, message)
|
||||||
|
if not value then
|
||||||
|
if async then
|
||||||
|
logger.error(message, {from = "assert_async"})
|
||||||
|
test_ok = false
|
||||||
|
else
|
||||||
|
error(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Verifies 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
|
||||||
|
|
||||||
|
-- Verifies string want contains string got.
|
||||||
|
function assert_contains(got, want)
|
||||||
|
assert_async(string.find(got, want),
|
||||||
|
string.format("got %q, wanted it to contain %q", got, want))
|
||||||
|
end
|
||||||
|
`
|
||||||
|
|
||||||
|
var consoleLogger = zerolog.New(zerolog.NewConsoleWriter())
|
||||||
|
|
||||||
|
func TestEmptyScript(t *testing.T) {
|
||||||
|
script := ""
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
|
||||||
|
_, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger(t *testing.T) {
|
||||||
|
script := `
|
||||||
|
local logger = require("logger")
|
||||||
|
logger.info("_test log entry_", {})
|
||||||
|
`
|
||||||
|
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
output := &strings.Builder{}
|
||||||
|
logger := zerolog.New(output)
|
||||||
|
|
||||||
|
_, err := luahost.NewFromReader(logger, extHost, strings.NewReader(script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, output.String(), "_test log entry_")
|
||||||
|
}
|
||||||
|
|
||||||
|
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(consoleLogger, 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(consoleLogger, 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(consoleLogger, 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, "Expected result from Emit()")
|
||||||
|
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, "Expected result from Emit()")
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("Got %v, wanted %v for addr %v", *got, want, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeforeMessageStored(t *testing.T) {
|
||||||
|
// Event to send.
|
||||||
|
msg := event.InboundMessage{
|
||||||
|
Mailboxes: []string{"one", "two"},
|
||||||
|
From: &mail.Address{Name: "From Name", Address: "from@example.com"},
|
||||||
|
To: []*mail.Address{
|
||||||
|
{Name: "To1 Name", Address: "to1@example.com"},
|
||||||
|
{Name: "To2 Name", Address: "to2@example.com"},
|
||||||
|
},
|
||||||
|
Subject: "inbound subj",
|
||||||
|
Size: 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register lua event listener.
|
||||||
|
script := `
|
||||||
|
async = true
|
||||||
|
|
||||||
|
function inbucket.before.message_stored(msg)
|
||||||
|
-- Verify incoming values.
|
||||||
|
assert_eq(msg.mailboxes, {"one", "two"})
|
||||||
|
assert_eq(msg.from.name, "From Name")
|
||||||
|
assert_eq(msg.from.address, "from@example.com")
|
||||||
|
assert_eq(2, #msg.to)
|
||||||
|
assert_eq(msg.to[1].name, "To1 Name")
|
||||||
|
assert_eq(msg.to[1].address, "to1@example.com")
|
||||||
|
assert_eq(msg.to[2].name, "To2 Name")
|
||||||
|
assert_eq(msg.to[2].address, "to2@example.com")
|
||||||
|
assert_eq(msg.subject, "inbound subj")
|
||||||
|
assert_eq(msg.size, 42)
|
||||||
|
notify:send(test_ok)
|
||||||
|
|
||||||
|
-- Generate response.
|
||||||
|
res = inbound_message.new()
|
||||||
|
res.mailboxes = {"resone", "restwo"}
|
||||||
|
res.from = address.new("Res From", "res@example.com")
|
||||||
|
res.to = {
|
||||||
|
address.new("To1 Res", "res1@example.com"),
|
||||||
|
address.new("To2 Res", "res2@example.com"),
|
||||||
|
}
|
||||||
|
res.subject = "res subj"
|
||||||
|
return res
|
||||||
|
end
|
||||||
|
`
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
luaHost, err := luahost.NewFromReader(consoleLogger, extHost, strings.NewReader(LuaInit+script), "test.lua")
|
||||||
|
require.NoError(t, err)
|
||||||
|
notify := luaHost.CreateChannel("notify")
|
||||||
|
|
||||||
|
// Send event to be accepted.
|
||||||
|
got := extHost.Events.BeforeMessageStored.Emit(&msg)
|
||||||
|
require.NotNil(t, got, "Expected result from Emit()")
|
||||||
|
|
||||||
|
// Verify Lua assertions passed.
|
||||||
|
assertNotified(t, notify)
|
||||||
|
|
||||||
|
// Verify response values.
|
||||||
|
want := &event.InboundMessage{
|
||||||
|
Mailboxes: []string{"resone", "restwo"},
|
||||||
|
From: &mail.Address{Name: "Res From", Address: "res@example.com"},
|
||||||
|
To: []*mail.Address{
|
||||||
|
{Name: "To1 Res", Address: "res1@example.com"},
|
||||||
|
{Name: "To2 Res", Address: "res2@example.com"},
|
||||||
|
},
|
||||||
|
Subject: "res subj",
|
||||||
|
Size: 0,
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, got, "Response InboundMessage did not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/cjoudrey/gluahttp"
|
||||||
|
"github.com/cosmotek/loguago"
|
||||||
|
json "github.com/inbucket/gopher-json"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
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.
|
||||||
|
logger zerolog.Logger // Logger exported to Lua scripts.
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStatePool(logger zerolog.Logger, funcProto *lua.FunctionProto) *statePool {
|
||||||
|
return &statePool{
|
||||||
|
funcProto: funcProto,
|
||||||
|
channels: make(map[string]chan lua.LValue),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newState creates a new LState and configures it. Lock must be held.
|
||||||
|
func (lp *statePool) newState() (*lua.LState, error) {
|
||||||
|
ls := lua.NewState()
|
||||||
|
|
||||||
|
logger := loguago.NewLogger(lp.logger)
|
||||||
|
|
||||||
|
// Load supplemental native modules.
|
||||||
|
ls.PreloadModule("http", gluahttp.NewHttpModule(&http.Client{}).Loader)
|
||||||
|
ls.PreloadModule("json", json.Loader)
|
||||||
|
ls.PreloadModule("logger", logger.Loader)
|
||||||
|
|
||||||
|
// Setup channels.
|
||||||
|
for name, ch := range lp.channels {
|
||||||
|
ls.SetGlobal(name, lua.LChannel(ch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register custom types.
|
||||||
|
registerInboundMessageType(ls)
|
||||||
|
registerInbucketTypes(ls)
|
||||||
|
registerMailAddressType(ls)
|
||||||
|
registerMessageMetadataType(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
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package luahost
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"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(zerolog.Nop(), 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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/storage"
|
||||||
|
"github.com/jhillyerd/enmime"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recvdTimeFmt to use in generated Received header.
|
||||||
|
const recvdTimeFmt = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||||
|
|
||||||
|
// Manager is the interface controllers use to interact with messages.
|
||||||
|
type Manager interface {
|
||||||
|
Deliver(
|
||||||
|
from *policy.Origin,
|
||||||
|
recipients []*policy.Recipient,
|
||||||
|
recvdHeader string,
|
||||||
|
content []byte,
|
||||||
|
) 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(
|
||||||
|
from *policy.Origin,
|
||||||
|
recipients []*policy.Recipient,
|
||||||
|
recvdHeader string,
|
||||||
|
source []byte,
|
||||||
|
) error {
|
||||||
|
logger := log.With().Str("module", "message").Logger()
|
||||||
|
|
||||||
|
// Parse envelope headers.
|
||||||
|
header, err := enmime.DecodeHeaders(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fromAddrs, err := enmime.ParseAddressList(header.Get("From"))
|
||||||
|
if err != nil || len(fromAddrs) == 0 {
|
||||||
|
// Failed to parse From header, use SMTP MAIL FROM instead.
|
||||||
|
fromAddrs = make([]*mail.Address, 1)
|
||||||
|
fromAddrs[0] = &from.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
toAddrs, err := enmime.ParseAddressList(header.Get("To"))
|
||||||
|
if err != nil {
|
||||||
|
// Failed to parse To header, use SMTP RCPT TO instead.
|
||||||
|
toAddrs = make([]*mail.Address, len(recipients))
|
||||||
|
for i, torecip := range recipients {
|
||||||
|
toAddrs[i] = &torecip.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := header.Get("Subject")
|
||||||
|
now := time.Now()
|
||||||
|
tstamp := now.UTC().Format(recvdTimeFmt)
|
||||||
|
|
||||||
|
// Process inbound message through extensions.
|
||||||
|
mailboxes := make([]string, len(recipients))
|
||||||
|
for i, recip := range recipients {
|
||||||
|
mailboxes[i] = recip.Mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct InboundMessage event and process through extensions.
|
||||||
|
inbound := &event.InboundMessage{
|
||||||
|
Mailboxes: mailboxes,
|
||||||
|
From: fromAddrs[0],
|
||||||
|
To: toAddrs,
|
||||||
|
Subject: subject,
|
||||||
|
Size: int64(len(source)),
|
||||||
|
}
|
||||||
|
|
||||||
|
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
|
||||||
|
if extResult == nil {
|
||||||
|
// Use address policy to determine deliverable mailboxes.
|
||||||
|
mailboxes = mailboxes[:0]
|
||||||
|
for _, recip := range recipients {
|
||||||
|
if recip.ShouldStore() {
|
||||||
|
mailboxes = append(mailboxes, recip.Mailbox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inbound.Mailboxes = mailboxes
|
||||||
|
} else {
|
||||||
|
// Event response overrides destination mailboxes and address policy.
|
||||||
|
inbound = extResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver to each mailbox.
|
||||||
|
for _, mb := range inbound.Mailboxes {
|
||||||
|
// Append recipient and timestamp to generated Recieved header.
|
||||||
|
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
|
||||||
|
|
||||||
|
// Deliver message.
|
||||||
|
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
|
||||||
|
delivery := &Delivery{
|
||||||
|
Meta: event.MessageMetadata{
|
||||||
|
Mailbox: mb,
|
||||||
|
From: inbound.From,
|
||||||
|
To: inbound.To,
|
||||||
|
Date: now,
|
||||||
|
Subject: inbound.Subject,
|
||||||
|
},
|
||||||
|
Reader: io.MultiReader(strings.NewReader(recvd), bytes.NewReader(source)),
|
||||||
|
}
|
||||||
|
id, err := s.Store.AddMessage(delivery)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Str("mailbox", mb).Err(err).Msg("Delivery failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit message stored event.
|
||||||
|
event := delivery.Meta
|
||||||
|
event.ID = id
|
||||||
|
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
package message_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeliverStoresMessages(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Attempt to deliver a message to two mailboxes.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte(`From: from@example.com
|
||||||
|
To: u1@example.com, u2@example.com
|
||||||
|
Subject: tsub
|
||||||
|
|
||||||
|
test email`),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assertMessageCount(t, sm, "u1@example.com", 1)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverStoresMessageNoFromHeader(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Attempt to deliver a message to two mailboxes.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte(`To: u1@example.com, u2@example.com
|
||||||
|
Subject: tsub
|
||||||
|
|
||||||
|
test email`),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assertMessageCount(t, sm, "u1@example.com", 1)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverStoresMessageNoToHeader(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Attempt to deliver a message to two mailboxes.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte(`From: from@example.com
|
||||||
|
Subject: tsub
|
||||||
|
|
||||||
|
test email`),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assertMessageCount(t, sm, "u1@example.com", 1)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverRespectsRecipientPolicy(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Attempt to deliver a message to two mailboxes.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@nostore.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect empty mailbox for nostore domain.
|
||||||
|
assertMessageCount(t, sm, "u1@nostore.com", 0)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverEmitsBeforeMessageStoredEventToHeader(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
var got *event.InboundMessage
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
got = &msg
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event, To header differs from RCPT TO.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte(`From: from@example.com
|
||||||
|
To: u1@example.com, u3@external.com
|
||||||
|
Subject: tsub
|
||||||
|
|
||||||
|
test email`),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
|
||||||
|
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
|
||||||
|
assert.Equal(t, &mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
|
||||||
|
assert.Equal(t, []*mail.Address{
|
||||||
|
{Name: "", Address: "u1@example.com"},
|
||||||
|
{Name: "", Address: "u3@external.com"},
|
||||||
|
}, got.To, "To not equal")
|
||||||
|
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
|
||||||
|
assert.Equal(t, int64(84), got.Size, "Size not equal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverEmitsBeforeMessageStoredEventRcptTo(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
var got *event.InboundMessage
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
got = &msg
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event, lacks To header.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
|
||||||
|
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
|
||||||
|
assert.Equal(t, &mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
|
||||||
|
assert.Equal(t, []*mail.Address{
|
||||||
|
{Name: "", Address: "u1@example.com"},
|
||||||
|
{Name: "", Address: "u2@example.com"},
|
||||||
|
}, got.To, "To not equal")
|
||||||
|
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
|
||||||
|
assert.Equal(t, int64(48), got.Size, "Size not equal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxes(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener rewrites destination mailboxes.
|
||||||
|
resp := msg
|
||||||
|
resp.Mailboxes = []string{"new1@example.com", "new2@nostore.com"}
|
||||||
|
return &resp
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect messages in only the mailboxes in the event response, and for the DiscardDomains
|
||||||
|
// policy to be ignored for nostore.com.
|
||||||
|
assertMessageCount(t, sm, "u1@example.com", 0)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 0)
|
||||||
|
assertMessageCount(t, sm, "new1@example.com", 1)
|
||||||
|
assertMessageCount(t, sm, "new2@nostore.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxesEmpty(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener clears destination mailboxes.
|
||||||
|
resp := msg
|
||||||
|
resp.Mailboxes = []string{}
|
||||||
|
return &resp
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect no messages the mailboxes.
|
||||||
|
assertMessageCount(t, sm, "u1@example.com", 0)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesBeforeMessageStoredEventResponseFields(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener rewrites destination mailboxes.
|
||||||
|
msg.Subject = "event subj"
|
||||||
|
msg.From = &mail.Address{Address: "from@event.com", Name: "From Event"}
|
||||||
|
|
||||||
|
// Changing To does not affect destination mailbox(es).
|
||||||
|
msg.To = []*mail.Address{
|
||||||
|
{Address: "to@event.com", Name: "To Event"},
|
||||||
|
{Address: "to2@event.com", Name: "To 2 Event"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size is read only, should have no effect.
|
||||||
|
msg.Size = 12345
|
||||||
|
|
||||||
|
return &msg
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify single message stored.
|
||||||
|
metadata, err := sm.GetMetadata("u1@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, metadata, 1, "mailbox has incorrect # of messages")
|
||||||
|
got := metadata[0]
|
||||||
|
|
||||||
|
// Verify metadata fields were overridden by event response values.
|
||||||
|
assert.Equal(t, "event subj", got.Subject, "Subject didn't match")
|
||||||
|
assert.Equal(t, "from@event.com", got.From.Address, "From Address didn't match")
|
||||||
|
assert.Equal(t, "From Event", got.From.Name, "From Name didn't match")
|
||||||
|
require.Len(t, got.To, 2)
|
||||||
|
assert.Equal(t, "to@event.com", got.To[0].Address, "To Address didn't match")
|
||||||
|
assert.Equal(t, "To Event", got.To[0].Name, "To Name didn't match")
|
||||||
|
assert.Equal(t, "to2@event.com", got.To[1].Address, "To Address didn't match")
|
||||||
|
assert.Equal(t, "To 2 Event", got.To[1].Name, "To Name didn't match")
|
||||||
|
assert.NotEqual(t, 12345, got.Size, "Size is read only")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip, _ := sm.AddrPolicy.NewRecipient("to@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]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")
|
||||||
|
assertMessageCount(t, sm, "to@example.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverBeforeAndAfterMessageStoredEvents(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive Before event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener rewrites destination mailboxes.
|
||||||
|
resp := msg
|
||||||
|
resp.Mailboxes = []string{"new1@example.com", "new2@example.com"}
|
||||||
|
return &resp
|
||||||
|
})
|
||||||
|
|
||||||
|
// After event listener.
|
||||||
|
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 2)
|
||||||
|
|
||||||
|
// Deliver a message to trigger events.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm mailbox names overriden by Before were sent to After event. Order is
|
||||||
|
// not guaranteed.
|
||||||
|
got1, err := listener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
got2, err := listener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := []string{got1.Mailbox, got2.Mailbox}
|
||||||
|
assert.Contains(t, got, "new1@example.com")
|
||||||
|
assert.Contains(t, got, "new2@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMessage(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Add a test message.
|
||||||
|
subject := "getMessage1"
|
||||||
|
id := addTestMessage(sm, "box1", subject)
|
||||||
|
|
||||||
|
// Verify retrieval of the test message.
|
||||||
|
msg, err := sm.GetMessage("box1", id)
|
||||||
|
require.NoError(t, err, "GetMessage must succeed")
|
||||||
|
require.NotNil(t, msg, "GetMessage must return a result")
|
||||||
|
assert.Equal(t, subject, msg.Subject)
|
||||||
|
assert.Contains(t, msg.Text(), fmt.Sprintf("about %q", subject))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkSeen(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Add a test message.
|
||||||
|
subject := "getMessage1"
|
||||||
|
id := addTestMessage(sm, "box1", subject)
|
||||||
|
|
||||||
|
// Verify test message unseen.
|
||||||
|
msg, err := sm.GetMessage("box1", id)
|
||||||
|
require.NoError(t, err, "GetMessage must succeed")
|
||||||
|
require.NotNil(t, msg, "GetMessage must return a result")
|
||||||
|
assert.False(t, msg.Seen, "msg should be unseen")
|
||||||
|
|
||||||
|
err = sm.MarkSeen("box1", id)
|
||||||
|
assert.NoError(t, err, "MarkSeen should succeed")
|
||||||
|
|
||||||
|
// Verify test message seen.
|
||||||
|
msg, err = sm.GetMessage("box1", id)
|
||||||
|
require.NoError(t, err, "GetMessage must succeed")
|
||||||
|
require.NotNil(t, msg, "GetMessage must return a result")
|
||||||
|
assert.True(t, msg.Seen, "msg should have been seen")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveMessage(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Add test messages.
|
||||||
|
id1 := addTestMessage(sm, "box1", "subject 1")
|
||||||
|
id2 := addTestMessage(sm, "box1", "subject 2")
|
||||||
|
id3 := addTestMessage(sm, "box1", "subject 3")
|
||||||
|
got, err := sm.GetMetadata("box1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3)
|
||||||
|
|
||||||
|
// Delete message 2 and verify.
|
||||||
|
err = sm.RemoveMessage("box1", id2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
got, err = sm.GetMetadata("box1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2, "Should be 2 messages remaining")
|
||||||
|
|
||||||
|
gotIDs := make([]string, 0, 3)
|
||||||
|
for _, msg := range got {
|
||||||
|
gotIDs = append(gotIDs, msg.ID)
|
||||||
|
}
|
||||||
|
assert.Contains(t, gotIDs, id1)
|
||||||
|
assert.Contains(t, gotIDs, id3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurgeMessages(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
// Add test messages.
|
||||||
|
_ = addTestMessage(sm, "box1", "subject 1")
|
||||||
|
_ = addTestMessage(sm, "box1", "subject 2")
|
||||||
|
_ = addTestMessage(sm, "box1", "subject 3")
|
||||||
|
got, err := sm.GetMetadata("box1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3)
|
||||||
|
|
||||||
|
// Purge and verify.
|
||||||
|
err = sm.PurgeMessages("box1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
got, err = sm.GetMetadata("box1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 0, "Purge should remove all mailbox messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceReader(t *testing.T) {
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
recvdHeader := "Received: xyz\n"
|
||||||
|
msgSource := `From: from@example.com
|
||||||
|
To: u1@example.com, u2@example.com
|
||||||
|
Subject: tsub
|
||||||
|
|
||||||
|
test email`
|
||||||
|
|
||||||
|
// Deliver mesage.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
err := sm.Deliver(origin, []*policy.Recipient{recip1}, recvdHeader, []byte(msgSource))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Find message ID.
|
||||||
|
msgs, err := sm.GetMetadata("u1@example.com")
|
||||||
|
require.NoError(t, err, "Failed to read mailbox")
|
||||||
|
require.Len(t, msgs, 1, "Unexpected mailbox len")
|
||||||
|
id := msgs[0].ID
|
||||||
|
|
||||||
|
// Read back and verify source.
|
||||||
|
r, err := sm.SourceReader("u1@example.com", id)
|
||||||
|
require.NoError(t, err, "SourceReader must succeed")
|
||||||
|
gotBytes, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, err, "Failed to read source")
|
||||||
|
|
||||||
|
got := string(gotBytes)
|
||||||
|
assert.Contains(t, got, recvdHeader, "Source should contain received header")
|
||||||
|
assert.Contains(t, got, msgSource, "Source should contain original message source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailboxForAddress(t *testing.T) {
|
||||||
|
// Configured for FullNaming.
|
||||||
|
sm, _ := testStoreManager()
|
||||||
|
|
||||||
|
addr := "u1@example.com"
|
||||||
|
got, err := sm.MailboxForAddress(addr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, addr, got, "FullNaming mode should return a full address for mailbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an empty StoreManager and extension Host pair, configured for testing.
|
||||||
|
func testStoreManager() (*message.StoreManager, *extension.Host) {
|
||||||
|
extHost := extension.NewHost()
|
||||||
|
|
||||||
|
sm := &message.StoreManager{
|
||||||
|
AddrPolicy: &policy.Addressing{
|
||||||
|
Config: &config.Root{
|
||||||
|
MailboxNaming: config.FullNaming,
|
||||||
|
SMTP: config.SMTP{
|
||||||
|
DefaultAccept: true,
|
||||||
|
DefaultStore: true,
|
||||||
|
RejectDomains: []string{"noaccept.com"},
|
||||||
|
DiscardDomains: []string{"nostore.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Store: test.NewStore(),
|
||||||
|
ExtHost: extHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
return sm, extHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a test message to the provided store, returning the new message ID.
|
||||||
|
func addTestMessage(sm *message.StoreManager, mailbox string, subject string) string {
|
||||||
|
from := mail.Address{Name: "From Test", Address: "from@example.com"}
|
||||||
|
to := mail.Address{Name: "To Test", Address: "to@example.com"}
|
||||||
|
delivery := &message.Delivery{
|
||||||
|
Meta: event.MessageMetadata{
|
||||||
|
Mailbox: mailbox,
|
||||||
|
From: &from,
|
||||||
|
To: []*mail.Address{&to},
|
||||||
|
Date: time.Now(),
|
||||||
|
Subject: subject,
|
||||||
|
},
|
||||||
|
Reader: strings.NewReader(fmt.Sprintf(
|
||||||
|
"From: %s\nTo: %s\nSubject: %s\n\nTest message about %q\n",
|
||||||
|
from, to, subject, subject,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := sm.Store.AddMessage(delivery)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMessageCount(t *testing.T, sm *message.StoreManager, mailbox string, count int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
metas, err := sm.GetMetadata(mailbox)
|
||||||
|
assert.NoError(t, err, "StoreManager GetMetadata failed")
|
||||||
|
|
||||||
|
got := len(metas)
|
||||||
|
if got != count {
|
||||||
|
t.Errorf("Mailbox %q got %v messages, wanted %v", mailbox, got, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Package message contains message handling logic.
|
||||||
|
package message
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
|
"github.com/inbucket/inbucket/v3/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 {
|
||||||
|
attachments := append([]*enmime.Part{}, m.env.Inlines...)
|
||||||
|
attachments = append(attachments, m.env.Attachments...)
|
||||||
|
return 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 io.NopCloser(d.Reader), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seen getter.
|
||||||
|
func (d *Delivery) Seen() bool {
|
||||||
|
return d.Meta.Seen
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user