mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-11-05 17:12:33 +00:00
Compare commits
236 Commits
v2.0.0
...
v3.0.0-beta3
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: Docker Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master", "develop" ]
|
||||||
|
pull_request:
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: docker/build-push-action@v1
|
||||||
|
with:
|
||||||
|
repository: inbucket/inbucket
|
||||||
|
push: false
|
||||||
|
tag_with_ref: true
|
||||||
+19
-2
@@ -21,11 +21,16 @@ _testmain.go
|
|||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# vim swp files
|
# vim files
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
tags
|
||||||
|
tags.*
|
||||||
|
|
||||||
# our binaries
|
# Desktop Services Store on macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Inbucket binaries
|
||||||
/client
|
/client
|
||||||
/client.exe
|
/client.exe
|
||||||
/inbucket
|
/inbucket
|
||||||
@@ -35,3 +40,15 @@ _testmain.go
|
|||||||
/cmd/client/client.exe
|
/cmd/client/client.exe
|
||||||
/cmd/inbucket/inbucket
|
/cmd/inbucket/inbucket
|
||||||
/cmd/inbucket/inbucket.exe
|
/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
|
||||||
|
|||||||
+48
-39
@@ -2,18 +2,25 @@ project_name: inbucket
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
owner: jhillyerd
|
owner: inbucket
|
||||||
name: inbucket
|
name: inbucket
|
||||||
name_template: '{{.Tag}}'
|
name_template: '{{.Tag}}'
|
||||||
|
|
||||||
brew:
|
brews:
|
||||||
commit_author:
|
- commit_author:
|
||||||
name: goreleaserbot
|
name: goreleaserbot
|
||||||
email: goreleaser@carlosbecker.com
|
email: goreleaser@carlosbecker.com
|
||||||
install: bin.install ""
|
install: bin.install ""
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod download
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- binary: inbucket
|
- id: inbucket
|
||||||
|
binary: inbucket
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -25,7 +32,10 @@ builds:
|
|||||||
- "6"
|
- "6"
|
||||||
main: ./cmd/inbucket
|
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: inbucket-client
|
- id: inbucket-client
|
||||||
|
binary: inbucket-client
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
@@ -38,35 +48,37 @@ builds:
|
|||||||
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:
|
||||||
format: tar.gz
|
- id: tarball
|
||||||
wrap_in_directory: true
|
format: tar.gz
|
||||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
wrap_in_directory: true
|
||||||
.Arm }}{{ end }}'
|
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||||
format_overrides:
|
.Arm }}{{ end }}'
|
||||||
- goos: windows
|
format_overrides:
|
||||||
format: zip
|
- goos: windows
|
||||||
files:
|
format: zip
|
||||||
- LICENSE*
|
files:
|
||||||
- README*
|
- LICENSE*
|
||||||
- CHANGELOG*
|
- README*
|
||||||
- etc/**/*
|
- CHANGELOG*
|
||||||
- ui/**/*
|
- etc/**/*
|
||||||
|
- ui/dist/**/*
|
||||||
|
- ui/greeting.html
|
||||||
|
|
||||||
nfpm:
|
nfpms:
|
||||||
vendor: inbucket.org
|
- formats:
|
||||||
homepage: https://www.inbucket.org/
|
- deb
|
||||||
maintainer: github@hillyerd.com
|
- rpm
|
||||||
description: All-in-one disposable webmail service.
|
vendor: inbucket.org
|
||||||
license: MIT
|
homepage: https://www.inbucket.org/
|
||||||
formats:
|
maintainer: github@hillyerd.com
|
||||||
- deb
|
description: All-in-one disposable webmail service.
|
||||||
- rpm
|
license: MIT
|
||||||
files:
|
files:
|
||||||
"ui/**/*": "/usr/local/share/inbucket/ui"
|
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
|
||||||
config_files:
|
config_files:
|
||||||
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
|
||||||
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
"ui/greeting.html": "/etc/inbucket/greeting.html"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: SNAPSHOT-{{ .Commit }}
|
name_template: SNAPSHOT-{{ .Commit }}
|
||||||
@@ -75,6 +87,3 @@ checksum:
|
|||||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||||
|
|
||||||
dist: dist
|
dist: dist
|
||||||
|
|
||||||
sign:
|
|
||||||
artifacts: none
|
|
||||||
|
|||||||
+41
-19
@@ -1,24 +1,46 @@
|
|||||||
language: go
|
dist: bionic
|
||||||
sudo: false
|
|
||||||
|
|
||||||
addons:
|
|
||||||
apt:
|
|
||||||
packages:
|
|
||||||
- rpm
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- DEPLOY_WITH_MAJOR="1.10"
|
global:
|
||||||
|
- GO111MODULE=on
|
||||||
|
|
||||||
before_script:
|
language: go
|
||||||
- go get github.com/golang/lint/golint
|
|
||||||
- make deps
|
|
||||||
|
|
||||||
go:
|
install:
|
||||||
- "1.10.1"
|
- "go get golang.org/x/lint/golint"
|
||||||
|
- "make deps"
|
||||||
|
|
||||||
deploy:
|
jobs:
|
||||||
provider: script
|
include:
|
||||||
script: etc/travis-deploy.sh
|
- go: "1.14.x"
|
||||||
on:
|
- go: "1.15.x"
|
||||||
tags: true
|
- language: elm
|
||||||
branch: master
|
elm: "latest-0.19.1"
|
||||||
|
elm_format: "latest-0.19.1"
|
||||||
|
elm_test: "latest-0.19.1"
|
||||||
|
node_js: "10.16.0"
|
||||||
|
install:
|
||||||
|
- "cd ui"
|
||||||
|
- "npm ci"
|
||||||
|
script:
|
||||||
|
- "elm-format --validate ."
|
||||||
|
- "npm run build"
|
||||||
|
- stage: deploy
|
||||||
|
go: "1.15.x"
|
||||||
|
before_install:
|
||||||
|
- "nvm install 10.19.0"
|
||||||
|
install:
|
||||||
|
- "cd ui"
|
||||||
|
- "npm ci"
|
||||||
|
- "npm run build"
|
||||||
|
- "cd .."
|
||||||
|
script: "curl -sL https://git.io/goreleaser | bash"
|
||||||
|
addons:
|
||||||
|
apt:
|
||||||
|
packages:
|
||||||
|
- rpm
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- name: deploy
|
||||||
|
if: tag IS present
|
||||||
|
|||||||
+89
-17
@@ -4,12 +4,80 @@ 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.0.0-beta3]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker `HEALTHCHECK`
|
||||||
|
- Mouse-out delay to improve pop-up menu navigation
|
||||||
|
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated frontend and backend dependencies, Docker image base
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved layout on mobile and wide displays
|
||||||
|
- Prevent unexpected input for modal dialogs
|
||||||
|
- Allow empty SMTP `MAIL FROM:<>`
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ability to name mailboxes after domain of email recipient, set via
|
||||||
|
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated JavaScript dependencies.
|
||||||
|
- Updated Go dependencies.
|
||||||
|
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- URLs to view/download attachments from REST API, #138
|
||||||
|
- Support for late EHLO, #141
|
||||||
|
|
||||||
|
|
||||||
|
## [v3.0.0-beta1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `posix-millis` field to REST message and header responses for easier date
|
||||||
|
parsing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Rewrote the user interface from scratch, it's now an Elm powered single page
|
||||||
|
application.
|
||||||
|
- Moved the Inbucket repository to its own GitHub organization.
|
||||||
|
- Update to enmime v0.5.0
|
||||||
|
|
||||||
|
|
||||||
|
## v2.1.0
|
||||||
|
|
||||||
|
No change from beta1.
|
||||||
|
|
||||||
|
|
||||||
|
## [v2.1.0-beta1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Use Go 1.11 modules for reproducible builds.
|
||||||
|
- SMTP TLS support (thanks kingforaday.)
|
||||||
|
- `INBUCKET_WEB_PPROF` configuration option for performance profiling.
|
||||||
|
- Godoc example for the REST API client.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Docker build now uses Go 1.11 and Alpine 3.8
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Render UTF-8 addresses correctly in both REST API and Web UI.
|
||||||
|
- Memory storage now correctly returns the newest message when asked for ID
|
||||||
|
`latest`.
|
||||||
|
|
||||||
|
|
||||||
## [v2.0.0] - 2018-05-05
|
## [v2.0.0] - 2018-05-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
|
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
|
||||||
- Disabled color log output on Windows, doesn't work there.
|
- Disabled color log output on Windows, doesn't work there.
|
||||||
|
|
||||||
|
|
||||||
## [v2.0.0-rc1] - 2018-04-07
|
## [v2.0.0-rc1] - 2018-04-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -79,7 +147,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
|
||||||
@@ -94,9 +162,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
|
||||||
@@ -159,18 +227,22 @@ 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
|
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
|
||||||
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
|
||||||
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
|
||||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
|
||||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
|
||||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
|
||||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
|
||||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
[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
|
||||||
@@ -181,9 +253,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- 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
|
||||||
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: `git flow release finish`
|
||||||
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
|
6. Push tags and wait for https://travis-ci.org/inbucket/inbucket build to
|
||||||
complete
|
complete
|
||||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -1,24 +1,36 @@
|
|||||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||||
|
|
||||||
# Build
|
# Install build-time dependencies
|
||||||
FROM golang:1.10-alpine as builder
|
FROM golang:1.15-alpine3.12 as builder
|
||||||
RUN apk add --no-cache --virtual .build-deps git make
|
RUN apk add --no-cache --virtual .build-deps g++ git make npm python3
|
||||||
WORKDIR /go/src/github.com/jhillyerd/inbucket
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV CGO_ENABLED 0
|
ENV CGO_ENABLED 0
|
||||||
RUN make clean deps
|
RUN make clean deps
|
||||||
|
WORKDIR /build/ui
|
||||||
|
RUN rm -rf dist elm-stuff node_modules
|
||||||
|
RUN npm ci
|
||||||
|
ADD https://github.com/elm/compiler/releases/download/0.19.1/binary-for-linux-64-bit.gz elm.gz
|
||||||
|
RUN gunzip elm.gz && chmod 755 elm && mv elm /usr/bin/
|
||||||
|
|
||||||
|
# Build server
|
||||||
|
WORKDIR /build
|
||||||
RUN go build -o inbucket \
|
RUN go build -o inbucket \
|
||||||
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
|
||||||
-v ./cmd/inbucket
|
-v ./cmd/inbucket
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
WORKDIR /build/ui
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Run in minimal image
|
# Run in minimal image
|
||||||
FROM alpine:3.7
|
FROM alpine:3.12
|
||||||
ENV SRC /go/src/github.com/jhillyerd/inbucket
|
RUN apk --no-cache add tzdata
|
||||||
WORKDIR /opt/inbucket
|
WORKDIR /opt/inbucket
|
||||||
RUN mkdir bin defaults ui
|
RUN mkdir bin defaults ui
|
||||||
COPY --from=builder $SRC/inbucket bin
|
COPY --from=builder /build/inbucket bin
|
||||||
|
COPY --from=builder /build/ui/dist ui
|
||||||
COPY etc/docker/defaults/greeting.html defaults
|
COPY etc/docker/defaults/greeting.html defaults
|
||||||
COPY ui ui
|
|
||||||
COPY etc/docker/defaults/start-inbucket.sh /
|
COPY etc/docker/defaults/start-inbucket.sh /
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -27,11 +39,15 @@ ENV INBUCKET_SMTP_TIMEOUT 30s
|
|||||||
ENV INBUCKET_POP3_TIMEOUT 30s
|
ENV INBUCKET_POP3_TIMEOUT 30s
|
||||||
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
|
||||||
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
|
||||||
|
ENV INBUCKET_WEB_UIDIR=ui
|
||||||
ENV INBUCKET_STORAGE_TYPE file
|
ENV INBUCKET_STORAGE_TYPE file
|
||||||
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
ENV INBUCKET_STORAGE_PARAMS path:/storage
|
||||||
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
|
||||||
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
|
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 2500 9000 1100
|
EXPOSE 2500 9000 1100
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
Inbucket
|
Inbucket
|
||||||
=============================================================================
|
=============================================================================
|
||||||
[][Build Status]
|
[][Build Status]
|
||||||
|
|
||||||
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. Once compiled,
|
||||||
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
||||||
are all built in).
|
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]
|
||||||
|
|
||||||

|

|
||||||
@@ -19,10 +22,17 @@ 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].
|
||||||
|
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Inbucket has automated [Docker Image] builds via Docker Hub. The `stable` tag
|
||||||
|
tracks our `master` branch (releases), `latest` tracks our unstable
|
||||||
|
`development` branch.
|
||||||
|
|
||||||
|
|
||||||
## Homebrew Tap
|
## Homebrew Tap
|
||||||
|
|
||||||
(currently broken, being tracked in [issue
|
(currently broken, being tracked in [issue
|
||||||
#68](https://github.com/jhillyerd/inbucket/issues/68))
|
#68](https://github.com/inbucket/inbucket/issues/68))
|
||||||
|
|
||||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||||
see the `README.md` there for installation instructions.
|
see the `README.md` there for installation instructions.
|
||||||
@@ -30,37 +40,52 @@ see the `README.md` there for installation instructions.
|
|||||||
|
|
||||||
## 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
|
||||||
|
npm i
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
go build ./cmd/inbucket
|
||||||
|
```
|
||||||
|
|
||||||
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
|
_Note:_ You may also use the included Makefile to build and test the Go binaries.
|
||||||
|
|
||||||
Edit etc/inbucket.conf and tailor to your environment. It should work on most
|
Inbucket reads its configuration from environment variables, but comes with
|
||||||
Unix and OS X machines as is. Launch the daemon:
|
built in sane defaults. It should work on most Unix and OS X machines as is.
|
||||||
|
Launch the daemon:
|
||||||
|
|
||||||
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
|
```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 easier to use.
|
||||||
|
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
Inbucket is written in [Google Go]
|
Inbucket is written in [Go]
|
||||||
|
|
||||||
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/master/CHANGELOG.md
|
||||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
[config.md]: https://github.com/inbucket/inbucket/blob/master/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/develop/CONTRIBUTING.md
|
||||||
|
[Docker Image]: https://www.inbucket.org/binaries/docker.html
|
||||||
|
[From Source]: https://www.inbucket.org/installation/from-source.html
|
||||||
|
[Go]: https://golang.org/
|
||||||
|
[Go API docs]: https://godoc.org/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/
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listCmd struct {
|
type listCmd struct {
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type matchCmd struct {
|
type matchCmd struct {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mboxCmd struct {
|
type mboxCmd struct {
|
||||||
|
|||||||
+31
-19
@@ -14,18 +14,19 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest"
|
"github.com/inbucket/inbucket/pkg/rest"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/pop3"
|
"github.com/inbucket/inbucket/pkg/server/pop3"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage/file"
|
"github.com/inbucket/inbucket/pkg/storage/file"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/inbucket/inbucket/pkg/webui"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -40,10 +41,8 @@ var (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Server uptime for status page.
|
// Server uptime for status page.
|
||||||
startTime := time.Now()
|
startTime := expvar.NewInt("startMillis")
|
||||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
startTime.Set(time.Now().UnixNano() / 1000000)
|
||||||
return time.Since(startTime) / time.Second
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Goroutine count for status page.
|
// Goroutine count for status page.
|
||||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||||
@@ -73,6 +72,7 @@ func main() {
|
|||||||
config.Usage()
|
config.Usage()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process configuration.
|
// Process configuration.
|
||||||
config.Version = version
|
config.Version = version
|
||||||
config.BuildDate = date
|
config.BuildDate = date
|
||||||
@@ -85,6 +85,7 @@ func main() {
|
|||||||
conf.POP3.Debug = true
|
conf.POP3.Debug = true
|
||||||
conf.SMTP.Debug = true
|
conf.SMTP.Debug = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger setup.
|
// Logger setup.
|
||||||
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,12 +93,15 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
startupLog := log.With().Str("phase", "startup").Logger()
|
startupLog := log.With().Str("phase", "startup").Logger()
|
||||||
|
|
||||||
// Setup signal handler.
|
// Setup signal handler.
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
||||||
// Initialize logging.
|
// Initialize logging.
|
||||||
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
|
||||||
Msg("Inbucket starting")
|
Msg("Inbucket starting")
|
||||||
|
|
||||||
// Write pidfile if requested.
|
// Write pidfile if requested.
|
||||||
if *pidfile != "" {
|
if *pidfile != "" {
|
||||||
pidf, err := os.Create(*pidfile)
|
pidf, err := os.Create(*pidfile)
|
||||||
@@ -109,6 +113,7 @@ func main() {
|
|||||||
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure internal services.
|
// Configure internal services.
|
||||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||||
shutdownChan := make(chan bool)
|
shutdownChan := make(chan bool)
|
||||||
@@ -120,20 +125,26 @@ func main() {
|
|||||||
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||||
addrPolicy := &policy.Addressing{Config: conf}
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||||
|
|
||||||
// Start Retention scanner.
|
// Start Retention scanner.
|
||||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||||
retentionScanner.Start()
|
retentionScanner.Start()
|
||||||
// Start HTTP server.
|
|
||||||
|
// Configure routes and start HTTP server.
|
||||||
|
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||||
|
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
|
||||||
|
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
rest.SetupRoutes(web.Router)
|
|
||||||
webui.SetupRoutes(web.Router)
|
|
||||||
go web.Start(rootCtx)
|
go web.Start(rootCtx)
|
||||||
|
|
||||||
// Start POP3 server.
|
// Start POP3 server.
|
||||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||||
go pop3Server.Start(rootCtx)
|
go pop3Server.Start(rootCtx)
|
||||||
|
|
||||||
// Start SMTP server.
|
// Start SMTP server.
|
||||||
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||||
go smtpServer.Start(rootCtx)
|
go smtpServer.Start(rootCtx)
|
||||||
|
|
||||||
// Loop forever waiting for signals or shutdown channel.
|
// Loop forever waiting for signals or shutdown channel.
|
||||||
signalLoop:
|
signalLoop:
|
||||||
for {
|
for {
|
||||||
@@ -156,6 +167,7 @@ signalLoop:
|
|||||||
break signalLoop
|
break signalLoop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for active connections to finish.
|
// Wait for active connections to finish.
|
||||||
go timedExit(*pidfile)
|
go timedExit(*pidfile)
|
||||||
smtpServer.Drain()
|
smtpServer.Drain()
|
||||||
|
|||||||
+76
-39
@@ -21,17 +21,19 @@ variables it supports:
|
|||||||
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
||||||
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
||||||
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
|
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_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
|
||||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||||
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
|
||||||
INBUCKET_WEB_UIDIR ui User interface dir
|
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_GREETINGFILE ui/greeting.html Home page greeting HTML
|
||||||
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
|
|
||||||
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
|
|
||||||
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
|
||||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||||
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
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_TYPE memory Storage impl: file or memory
|
||||||
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
|
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
|
||||||
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
|
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
|
||||||
@@ -75,8 +77,14 @@ Prior to the addition of the mailbox naming setting, Inbucket always operated in
|
|||||||
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
local mode. Regardless of this setting, the `+` wildcard/extension is not
|
||||||
incorporated into the mailbox name.
|
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`
|
- Default: `local`
|
||||||
- Values: one of `local` or `full`
|
- Values: one of `local` or `full` or `domain`
|
||||||
|
|
||||||
|
|
||||||
## SMTP
|
## SMTP
|
||||||
@@ -202,6 +210,36 @@ to the public internet.
|
|||||||
- Default: `300s`
|
- Default: `300s`
|
||||||
- Values: Duration ending in `s` for seconds, `m` for minutes
|
- 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
|
## POP3
|
||||||
|
|
||||||
@@ -253,6 +291,24 @@ Inbucket to listen on all available network interfaces.
|
|||||||
|
|
||||||
- Default: `0.0.0.0:9000`
|
- 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
|
### UI Directory
|
||||||
|
|
||||||
`INBUCKET_WEB_UIDIR`
|
`INBUCKET_WEB_UIDIR`
|
||||||
@@ -264,7 +320,7 @@ doesn't contain the `ui` directory at startup.
|
|||||||
Inbucket will load templates from the `templates` sub-directory, and serve
|
Inbucket will load templates from the `templates` sub-directory, and serve
|
||||||
static assets from the `static` sub-directory.
|
static assets from the `static` sub-directory.
|
||||||
|
|
||||||
- Default: `ui`
|
- Default: `ui/dist`
|
||||||
- Values: Operating system specific path syntax
|
- Values: Operating system specific path syntax
|
||||||
|
|
||||||
### Greeting HTML File
|
### Greeting HTML File
|
||||||
@@ -277,39 +333,6 @@ Inbucket installation, as well as link to REST documentation, etc.
|
|||||||
|
|
||||||
- Default: `ui/greeting.html`
|
- Default: `ui/greeting.html`
|
||||||
|
|
||||||
### Template Caching
|
|
||||||
|
|
||||||
`INBUCKET_WEB_TEMPLATECACHE`
|
|
||||||
|
|
||||||
Tells Inbucket to cache parsed template files. This should be left as default
|
|
||||||
unless you are a developer working on the Inbucket web interface.
|
|
||||||
|
|
||||||
- Default: `true`
|
|
||||||
- Values: `true` or `false`
|
|
||||||
|
|
||||||
### Mailbox Prompt
|
|
||||||
|
|
||||||
`INBUCKET_WEB_MAILBOXPROMPT`
|
|
||||||
|
|
||||||
Text prompt displayed to the right of the mailbox name input field in the web
|
|
||||||
interface. Can be used to nudge your users into typing just the mailbox name
|
|
||||||
instead of an entire email address.
|
|
||||||
|
|
||||||
Set to an empty string to hide the prompt.
|
|
||||||
|
|
||||||
- Default: `@inbucket`
|
|
||||||
|
|
||||||
### Cookie Authentication Key
|
|
||||||
|
|
||||||
`INBUCKET_WEB_COOKIEAUTHKEY`
|
|
||||||
|
|
||||||
Inbucket stores session information in an encrypted browser cookie. Unless
|
|
||||||
specified, Inbucket generates a random key at startup. The only notable data
|
|
||||||
stored in a user session is the list of recently accessed mailboxes.
|
|
||||||
|
|
||||||
- Default: None
|
|
||||||
- Value: Text string, no particular format required
|
|
||||||
|
|
||||||
### Monitor Visible
|
### Monitor Visible
|
||||||
|
|
||||||
`INBUCKET_WEB_MONITORVISIBLE`
|
`INBUCKET_WEB_MONITORVISIBLE`
|
||||||
@@ -344,6 +367,20 @@ them.
|
|||||||
- Default: `30`
|
- Default: `30`
|
||||||
- Values: Integer greater than or equal to 0
|
- 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
|
## Storage
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -3,16 +3,32 @@
|
|||||||
# description: Developer friendly Inbucket configuration
|
# description: Developer friendly Inbucket configuration
|
||||||
|
|
||||||
export INBUCKET_LOGLEVEL="debug"
|
export INBUCKET_LOGLEVEL="debug"
|
||||||
|
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
|
||||||
|
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
|
||||||
|
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
|
||||||
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
||||||
|
#export INBUCKET_SMTP_DEFAULTSTORE="false"
|
||||||
|
export INBUCKET_SMTP_STOREDOMAINS="important.local"
|
||||||
export INBUCKET_WEB_TEMPLATECACHE="false"
|
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
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_TYPE="file"
|
||||||
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
|
||||||
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
|
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||||
|
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
|
||||||
|
|
||||||
if ! test -x ./inbucket; then
|
if ! test -x ./inbucket; then
|
||||||
echo "$PWD/inbucket not found/executable!" >&2
|
echo "$PWD/inbucket not found/executable!" >&2
|
||||||
echo "Run this script from the inbucket root directory after running make" >&2
|
echo "Run this script from the inbucket root directory after running make." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||||
|
if ! test -f "$index"; then
|
||||||
|
echo "$index does not exist!" >&2
|
||||||
|
echo "Run 'npm run build' from the 'ui' directory." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
# 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=""
|
||||||
@@ -39,11 +39,11 @@ main() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -56,3 +56,6 @@ 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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module github.com/inbucket/inbucket
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
|
||||||
|
github.com/google/subcommands v1.2.0
|
||||||
|
github.com/gorilla/css v1.0.0
|
||||||
|
github.com/gorilla/mux v1.8.0
|
||||||
|
github.com/gorilla/websocket v1.4.2
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
|
||||||
|
github.com/jhillyerd/enmime v0.8.1
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.4
|
||||||
|
github.com/olekukonko/tablewriter v0.0.4 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rs/zerolog v1.19.0
|
||||||
|
github.com/stretchr/testify v1.6.1
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202
|
||||||
|
golang.org/x/text v0.3.3 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
go 1.13
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||||
|
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||||
|
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
|
||||||
|
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
|
||||||
|
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||||
|
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
|
||||||
|
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
|
||||||
|
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
|
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/jhillyerd/enmime v0.8.1 h1:Kz4xj3sJJ4Ju8e+w/7v9H4Matv5ijPgv7UkhPf+C15I=
|
||||||
|
github.com/jhillyerd/enmime v0.8.1/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE=
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
|
github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
|
github.com/rs/zerolog v1.19.0 h1:hYz4ZVdUgjXTBUmrkrw55j1nHx68LfOKIQk5IYtyScg=
|
||||||
|
github.com/rs/zerolog v1.19.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
|
||||||
|
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
|
||||||
|
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
+11
-6
@@ -8,7 +8,7 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/kelseyhightower/envconfig"
|
"github.com/kelseyhightower/envconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ const (
|
|||||||
UnknownNaming mbNaming = iota
|
UnknownNaming mbNaming = iota
|
||||||
LocalNaming
|
LocalNaming
|
||||||
FullNaming
|
FullNaming
|
||||||
|
DomainNaming
|
||||||
)
|
)
|
||||||
|
|
||||||
// Decode a naming strategy from string.
|
// Decode a naming strategy from string.
|
||||||
@@ -47,6 +48,8 @@ func (n *mbNaming) Decode(v string) error {
|
|||||||
*n = LocalNaming
|
*n = LocalNaming
|
||||||
case "full":
|
case "full":
|
||||||
*n = FullNaming
|
*n = FullNaming
|
||||||
|
case "domain":
|
||||||
|
*n = DomainNaming
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,7 @@ func (n *mbNaming) Decode(v string) error {
|
|||||||
// Root contains global configuration, and structs with for specific sub-systems.
|
// Root contains global configuration, and structs with for specific sub-systems.
|
||||||
type Root struct {
|
type Root struct {
|
||||||
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
|
||||||
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
|
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
|
||||||
SMTP SMTP
|
SMTP SMTP
|
||||||
POP3 POP3
|
POP3 POP3
|
||||||
Web Web
|
Web Web
|
||||||
@@ -76,6 +79,9 @@ type SMTP struct {
|
|||||||
StoreDomains []string `desc:"Domains to store mail for"`
|
StoreDomains []string `desc:"Domains to store mail for"`
|
||||||
DiscardDomains []string `desc:"Domains to discard mail for"`
|
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||||
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
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"`
|
Debug bool `ignored:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +96,12 @@ type POP3 struct {
|
|||||||
// Web contains the HTTP server configuration.
|
// Web contains the HTTP server configuration.
|
||||||
type Web struct {
|
type Web struct {
|
||||||
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
|
||||||
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
|
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"`
|
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
|
||||||
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
|
|
||||||
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
|
|
||||||
CookieAuthKey string `desc:"Session cipher key (text)"`
|
|
||||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||||
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
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.
|
// Storage contains the mail store configuration.
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func (s *StoreManager) Deliver(
|
|||||||
broadcast := msghub.Message{
|
broadcast := msghub.Message{
|
||||||
Mailbox: to.Mailbox,
|
Mailbox: to.Mailbox,
|
||||||
ID: id,
|
ID: id,
|
||||||
From: delivery.From().String(),
|
From: stringutil.StringAddress(delivery.From()),
|
||||||
To: stringutil.StringAddressList(delivery.To()),
|
To: stringutil.StringAddressList(delivery.To()),
|
||||||
Subject: delivery.Subject(),
|
Subject: delivery.Subject(),
|
||||||
Date: delivery.Date(),
|
Date: delivery.Date(),
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"net/textproto"
|
"net/textproto"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata holds information about a message, but not the content.
|
// Metadata holds information about a message, but not the content.
|
||||||
|
|||||||
+22
-8
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Addressing handles email address policy.
|
// Addressing handles email address policy.
|
||||||
@@ -28,6 +28,20 @@ func (a *Addressing) ExtractMailbox(address string) (string, error) {
|
|||||||
if a.Config.MailboxNaming == config.LocalNaming {
|
if a.Config.MailboxNaming == config.LocalNaming {
|
||||||
return local, nil
|
return local, nil
|
||||||
}
|
}
|
||||||
|
if a.Config.MailboxNaming == config.DomainNaming {
|
||||||
|
// If no domain is specified, assume this is being
|
||||||
|
// used for mailbox lookup via the API.
|
||||||
|
if domain == "" {
|
||||||
|
if ValidateDomainPart(local) == false {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", local, address)
|
||||||
|
}
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if ValidateDomainPart(domain) == false {
|
||||||
|
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
if a.Config.MailboxNaming != config.FullNaming {
|
if a.Config.MailboxNaming != config.FullNaming {
|
||||||
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
|
||||||
}
|
}
|
||||||
@@ -128,8 +142,8 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
hasAlphaNum = true
|
hasAlphaNum = true
|
||||||
labelLen++
|
labelLen++
|
||||||
case c == '-':
|
case c == '-':
|
||||||
if prev == '.' {
|
if prev == '.' || prev == '-' {
|
||||||
// Cannot lead with hyphen.
|
// Cannot lead with hyphen or double hyphen.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case c == '.':
|
case c == '.':
|
||||||
@@ -159,16 +173,16 @@ func ValidateDomainPart(domain string) bool {
|
|||||||
// domain part is optional and not validated.
|
// domain part is optional and not validated.
|
||||||
func parseEmailAddress(address string) (local string, domain string, err error) {
|
func parseEmailAddress(address string) (local string, domain string, err error) {
|
||||||
if address == "" {
|
if address == "" {
|
||||||
return "", "", fmt.Errorf("Empty address")
|
return "", "", fmt.Errorf("empty address")
|
||||||
}
|
}
|
||||||
if len(address) > 320 {
|
if len(address) > 320 {
|
||||||
return "", "", fmt.Errorf("Address exceeds 320 characters")
|
return "", "", fmt.Errorf("address exceeds 320 characters")
|
||||||
}
|
}
|
||||||
if address[0] == '@' {
|
if address[0] == '@' {
|
||||||
return "", "", fmt.Errorf("Address cannot start with @ symbol")
|
return "", "", fmt.Errorf("address cannot start with @ symbol")
|
||||||
}
|
}
|
||||||
if address[0] == '.' {
|
if address[0] == '.' {
|
||||||
return "", "", fmt.Errorf("Address cannot start with a period")
|
return "", "", fmt.Errorf("address cannot start with a period")
|
||||||
}
|
}
|
||||||
// Loop over address parsing out local part.
|
// Loop over address parsing out local part.
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
|
|||||||
+123
-65
@@ -4,8 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldAcceptDomain(t *testing.T) {
|
func TestShouldAcceptDomain(t *testing.T) {
|
||||||
@@ -125,111 +125,139 @@ func TestShouldStoreDomain(t *testing.T) {
|
|||||||
func TestExtractMailboxValid(t *testing.T) {
|
func TestExtractMailboxValid(t *testing.T) {
|
||||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
|
|
||||||
testTable := []struct {
|
testTable := []struct {
|
||||||
input string // Input to test
|
input string // Input to test
|
||||||
local string // Expected output when mailbox naming = local
|
local string // Expected output when mailbox naming = local
|
||||||
full string // Expected output when mailbox naming = full
|
full string // Expected output when mailbox naming = full
|
||||||
|
domain string // Expected output when mailbox naming = domain
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
input: "mailbox",
|
input: "mailbox",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox",
|
full: "mailbox",
|
||||||
|
domain: "mailbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user123",
|
input: "user123",
|
||||||
local: "user123",
|
local: "user123",
|
||||||
full: "user123",
|
full: "user123",
|
||||||
|
domain: "user123",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "MailBOX",
|
input: "MailBOX",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox",
|
full: "mailbox",
|
||||||
|
domain: "mailbox",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "First.Last",
|
input: "First.Last",
|
||||||
local: "first.last",
|
local: "first.last",
|
||||||
full: "first.last",
|
full: "first.last",
|
||||||
|
domain: "first.last",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user+label",
|
input: "user+label",
|
||||||
local: "user",
|
local: "user",
|
||||||
full: "user",
|
full: "user",
|
||||||
|
domain: "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars!#$%",
|
input: "chars!#$%",
|
||||||
local: "chars!#$%",
|
local: "chars!#$%",
|
||||||
full: "chars!#$%",
|
full: "chars!#$%",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars&'*-",
|
input: "chars&'*-",
|
||||||
local: "chars&'*-",
|
local: "chars&'*-",
|
||||||
full: "chars&'*-",
|
full: "chars&'*-",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars=/?^",
|
input: "chars=/?^",
|
||||||
local: "chars=/?^",
|
local: "chars=/?^",
|
||||||
full: "chars=/?^",
|
full: "chars=/?^",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars_`.{",
|
input: "chars_`.{",
|
||||||
local: "chars_`.{",
|
local: "chars_`.{",
|
||||||
full: "chars_`.{",
|
full: "chars_`.{",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars|}~",
|
input: "chars|}~",
|
||||||
local: "chars|}~",
|
local: "chars|}~",
|
||||||
full: "chars|}~",
|
full: "chars|}~",
|
||||||
|
domain: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "mailbox@domain.com",
|
input: "mailbox@domain.com",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox@domain.com",
|
full: "mailbox@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user123@domain.com",
|
input: "user123@domain.com",
|
||||||
local: "user123",
|
local: "user123",
|
||||||
full: "user123@domain.com",
|
full: "user123@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "MailBOX@domain.com",
|
input: "MailBOX@domain.com",
|
||||||
local: "mailbox",
|
local: "mailbox",
|
||||||
full: "mailbox@domain.com",
|
full: "mailbox@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "First.Last@domain.com",
|
input: "First.Last@domain.com",
|
||||||
local: "first.last",
|
local: "first.last",
|
||||||
full: "first.last@domain.com",
|
full: "first.last@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "user+label@domain.com",
|
input: "user+label@domain.com",
|
||||||
local: "user",
|
local: "user",
|
||||||
full: "user@domain.com",
|
full: "user@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars!#$%@domain.com",
|
input: "chars!#$%@domain.com",
|
||||||
local: "chars!#$%",
|
local: "chars!#$%",
|
||||||
full: "chars!#$%@domain.com",
|
full: "chars!#$%@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars&'*-@domain.com",
|
input: "chars&'*-@domain.com",
|
||||||
local: "chars&'*-",
|
local: "chars&'*-",
|
||||||
full: "chars&'*-@domain.com",
|
full: "chars&'*-@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars=/?^@domain.com",
|
input: "chars=/?^@domain.com",
|
||||||
local: "chars=/?^",
|
local: "chars=/?^",
|
||||||
full: "chars=/?^@domain.com",
|
full: "chars=/?^@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars_`.{@domain.com",
|
input: "chars_`.{@domain.com",
|
||||||
local: "chars_`.{",
|
local: "chars_`.{",
|
||||||
full: "chars_`.{@domain.com",
|
full: "chars_`.{@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: "chars|}~@domain.com",
|
input: "chars|}~@domain.com",
|
||||||
local: "chars|}~",
|
local: "chars|}~",
|
||||||
full: "chars|}~@domain.com",
|
full: "chars|}~@domain.com",
|
||||||
|
domain: "domain.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "chars|}~@example.co.uk",
|
||||||
|
local: "chars|}~",
|
||||||
|
full: "chars|}~@example.co.uk",
|
||||||
|
domain: "example.co.uk",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range testTable {
|
for _, tc := range testTable {
|
||||||
@@ -247,12 +275,20 @@ func TestExtractMailboxValid(t *testing.T) {
|
|||||||
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if result, err := domainPolicy.ExtractMailbox(tc.input); tc.domain != "" && err != nil {
|
||||||
|
t.Errorf("Error while parsing with domain naming %q: %v", tc.input, err)
|
||||||
|
} else {
|
||||||
|
if result != tc.domain {
|
||||||
|
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.domain, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractMailboxInvalid(t *testing.T) {
|
func TestExtractMailboxInvalid(t *testing.T) {
|
||||||
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
|
||||||
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
|
||||||
|
domainPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.DomainNaming}}
|
||||||
// Test local mailbox naming policy.
|
// Test local mailbox naming policy.
|
||||||
localInvalidTable := []struct {
|
localInvalidTable := []struct {
|
||||||
input, msg string
|
input, msg string
|
||||||
@@ -282,6 +318,28 @@ func TestExtractMailboxInvalid(t *testing.T) {
|
|||||||
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Test domain mailbox naming policy.
|
||||||
|
domainInvalidTable := []struct {
|
||||||
|
input, msg string
|
||||||
|
}{
|
||||||
|
{"", "Empty mailbox name is not permitted"},
|
||||||
|
{"user@host@domain.com", "@ symbol not permitted"},
|
||||||
|
{"first.last@dom ain.com", "Space not permitted"},
|
||||||
|
{"first\"last@domain.com", "Double quote not permitted"},
|
||||||
|
{"first\nlast@domain.com", "Control chars not permitted"},
|
||||||
|
{"first.last@chars!#$%.com", "Invalid domain name"},
|
||||||
|
{"first.last@.example.com", "Domain cannot start with dot"},
|
||||||
|
{"first.last@-example.com", "Domain canont start with dash"},
|
||||||
|
{"first.last@example.com-", "Domain cannot end with dash"},
|
||||||
|
{"first.last@example..com", "Domain cannot contain double dots"},
|
||||||
|
{"first.last@example--com", "Domain cannot contain double dashes"},
|
||||||
|
{"first.last@example.-com", "Domain cannot contain concecutive symbols"},
|
||||||
|
}
|
||||||
|
for _, tt := range domainInvalidTable {
|
||||||
|
if _, err := domainPolicy.ExtractMailbox(tt.input); err == nil {
|
||||||
|
t.Errorf("Didn't get an error while parsing in domain mode %q: %v", tt.input, tt.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDomain(t *testing.T) {
|
func TestValidateDomain(t *testing.T) {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MailboxListV1 renders a list of messages in a mailbox
|
// MailboxListV1 renders a list of messages in a mailbox
|
||||||
@@ -31,14 +31,15 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||||
Mailbox: name,
|
Mailbox: name,
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
From: msg.From.String(),
|
From: stringutil.StringAddress(msg.From),
|
||||||
To: stringutil.StringAddressList(msg.To),
|
To: stringutil.StringAddressList(msg.To),
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
Date: msg.Date,
|
Date: msg.Date,
|
||||||
Size: msg.Size,
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
Seen: msg.Seen,
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return web.RenderJSON(w, jmessages)
|
return web.RenderJSON(w, jmessages)
|
||||||
@@ -64,28 +65,30 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
|
||||||
for i, part := range attachParts {
|
for i, part := range attachParts {
|
||||||
content := part.Content
|
content := part.Content
|
||||||
var checksum = md5.Sum(content)
|
// Example URL: http://localhost/serve/mailbox/swaks/0001/attach/0/favicon.png
|
||||||
|
link := "http://" + req.Host + "/serve/mailbox/" + name + "/" + id + "/attach/" +
|
||||||
|
strconv.Itoa(i) + "/" + part.FileName
|
||||||
|
checksum := md5.Sum(content)
|
||||||
attachments[i] = &model.JSONMessageAttachmentV1{
|
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||||
ContentType: part.ContentType,
|
ContentType: part.ContentType,
|
||||||
FileName: part.FileName,
|
FileName: part.FileName,
|
||||||
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
|
DownloadLink: link,
|
||||||
strconv.Itoa(i) + "/" + part.FileName,
|
ViewLink: link,
|
||||||
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
|
MD5: hex.EncodeToString(checksum[:]),
|
||||||
strconv.Itoa(i) + "/" + part.FileName,
|
|
||||||
MD5: hex.EncodeToString(checksum[:]),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return web.RenderJSON(w,
|
return web.RenderJSON(w,
|
||||||
&model.JSONMessageV1{
|
&model.JSONMessageV1{
|
||||||
Mailbox: name,
|
Mailbox: name,
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
From: msg.From.String(),
|
From: stringutil.StringAddress(msg.From),
|
||||||
To: stringutil.StringAddressList(msg.To),
|
To: stringutil.StringAddressList(msg.To),
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
Date: msg.Date,
|
Date: msg.Date,
|
||||||
Size: msg.Size,
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
Seen: msg.Seen,
|
Size: msg.Size,
|
||||||
Header: msg.Header(),
|
Seen: msg.Seen,
|
||||||
|
Header: msg.Header(),
|
||||||
Body: &model.JSONMessageBodyV1{
|
Body: &model.JSONMessageBodyV1{
|
||||||
Text: msg.Text(),
|
Text: msg.Text(),
|
||||||
HTML: msg.HTML(),
|
HTML: msg.HTML(),
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
baseURL = "http://localhost/api/v1"
|
|
||||||
|
|
||||||
// JSON map keys
|
// JSON map keys
|
||||||
mailboxKey = "mailbox"
|
mailboxKey = "mailbox"
|
||||||
idKey = "id"
|
idKey = "id"
|
||||||
@@ -37,7 +35,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
logbuf := setupWebServer(mm)
|
logbuf := setupWebServer(mm)
|
||||||
|
|
||||||
// Test invalid mailbox name
|
// Test invalid mailbox name
|
||||||
w, err := testRestGet(baseURL + "/mailbox/foo%20bar")
|
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
|
||||||
expectCode := 500
|
expectCode := 500
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -47,7 +45,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test empty mailbox
|
// Test empty mailbox
|
||||||
w, err = testRestGet(baseURL + "/mailbox/empty")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/empty")
|
||||||
expectCode = 200
|
expectCode = 200
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -57,7 +55,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test Mailbox error
|
// Test Mailbox error
|
||||||
w, err = testRestGet(baseURL + "/mailbox/messageserr")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/messageserr")
|
||||||
expectCode = 500
|
expectCode = 500
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -89,7 +87,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||||
|
|
||||||
// Check return code
|
// Check return code
|
||||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||||
expectCode = 200
|
expectCode = 200
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -114,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
|
||||||
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
decodedStringEquals(t, result, "[0]/subject", "subject 1")
|
||||||
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "[0]/posix-millis", 1328119872000)
|
||||||
decodedNumberEquals(t, result, "[0]/size", 0)
|
decodedNumberEquals(t, result, "[0]/size", 0)
|
||||||
decodedBoolEquals(t, result, "[0]/seen", false)
|
decodedBoolEquals(t, result, "[0]/seen", false)
|
||||||
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
decodedStringEquals(t, result, "[1]/mailbox", "good")
|
||||||
@@ -122,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
|
|||||||
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
|
||||||
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
decodedStringEquals(t, result, "[1]/subject", "subject 2")
|
||||||
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
|
||||||
|
decodedNumberEquals(t, result, "[1]/posix-millis", 1341162672000)
|
||||||
decodedNumberEquals(t, result, "[1]/size", 0)
|
decodedNumberEquals(t, result, "[1]/size", 0)
|
||||||
decodedBoolEquals(t, result, "[1]/seen", false)
|
decodedBoolEquals(t, result, "[1]/seen", false)
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ func TestRestMessage(t *testing.T) {
|
|||||||
logbuf := setupWebServer(mm)
|
logbuf := setupWebServer(mm)
|
||||||
|
|
||||||
// Test invalid mailbox name
|
// Test invalid mailbox name
|
||||||
w, err := testRestGet(baseURL + "/mailbox/foo%20bar/0001")
|
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
|
||||||
expectCode := 500
|
expectCode := 500
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -149,7 +149,7 @@ func TestRestMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test requesting a message that does not exist
|
// Test requesting a message that does not exist
|
||||||
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/empty/0001")
|
||||||
expectCode = 404
|
expectCode = 404
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -159,7 +159,7 @@ func TestRestMessage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test GetMessage error
|
// Test GetMessage error
|
||||||
w, err = testRestGet(baseURL + "/mailbox/messageerr/0001")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/messageerr/0001")
|
||||||
expectCode = 500
|
expectCode = 500
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -196,12 +196,16 @@ func TestRestMessage(t *testing.T) {
|
|||||||
"From": []string{"noreply@inbucket.org"},
|
"From": []string{"noreply@inbucket.org"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Attachments: []*enmime.Part{{
|
||||||
|
FileName: "favicon.png",
|
||||||
|
ContentType: "image/png",
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mm.AddMessage("good", msg1)
|
mm.AddMessage("good", msg1)
|
||||||
|
|
||||||
// Check return code
|
// Check return code
|
||||||
w, err = testRestGet(baseURL + "/mailbox/good/0001")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good/0001")
|
||||||
expectCode = 200
|
expectCode = 200
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -223,6 +227,7 @@ func TestRestMessage(t *testing.T) {
|
|||||||
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
|
||||||
decodedStringEquals(t, result, "subject", "subject 1")
|
decodedStringEquals(t, result, "subject", "subject 1")
|
||||||
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
|
||||||
|
decodedNumberEquals(t, result, "posix-millis", 1328119872000)
|
||||||
decodedNumberEquals(t, result, "size", 0)
|
decodedNumberEquals(t, result, "size", 0)
|
||||||
decodedBoolEquals(t, result, "seen", true)
|
decodedBoolEquals(t, result, "seen", true)
|
||||||
decodedStringEquals(t, result, "body/text", "This is some text")
|
decodedStringEquals(t, result, "body/text", "This is some text")
|
||||||
@@ -230,6 +235,10 @@ func TestRestMessage(t *testing.T) {
|
|||||||
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
|
||||||
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
|
||||||
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/filename", "favicon.png")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/content-type", "image/png")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/download-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||||
|
decodedStringEquals(t, result, "attachments/[0]/view-link", "http://localhost/serve/mailbox/good/0001/attach/0/favicon.png")
|
||||||
|
|
||||||
if t.Failed() {
|
if t.Failed() {
|
||||||
// Wait for handler to finish logging
|
// Wait for handler to finish logging
|
||||||
@@ -264,7 +273,7 @@ func TestRestMarkSeen(t *testing.T) {
|
|||||||
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
mm.AddMessage("good", &message.Message{Metadata: meta1})
|
||||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||||
// Mark one read.
|
// Mark one read.
|
||||||
w, err := testRestPatch(baseURL+"/mailbox/good/0002", `{"seen":true}`)
|
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
|
||||||
expectCode := 200
|
expectCode := 200
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -273,7 +282,7 @@ func TestRestMarkSeen(t *testing.T) {
|
|||||||
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||||
}
|
}
|
||||||
// Get mailbox.
|
// Get mailbox.
|
||||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||||
expectCode = 200
|
expectCode = 200
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client accesses the Inbucket REST API v1
|
// Client accesses the Inbucket REST API v1
|
||||||
|
|||||||
@@ -1,183 +1,259 @@
|
|||||||
package client
|
package client_test
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
func TestClientV1ListMailbox(t *testing.T) {
|
func TestClientV1ListMailbox(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
listHandler := &jsonHandler{json: `[
|
||||||
|
{
|
||||||
|
"mailbox": "testbox",
|
||||||
|
"id": "1",
|
||||||
|
"from": "fromuser",
|
||||||
|
"subject": "test subject",
|
||||||
|
"date": "2013-10-15T16:12:02.231532239-07:00",
|
||||||
|
"size": 264,
|
||||||
|
"seen": true
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
|
||||||
|
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
headers, err := c.ListMailbox("testbox")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mth := &mockHTTPClient{}
|
|
||||||
c.client = mth
|
|
||||||
|
|
||||||
// Method under test
|
if len(headers) != 1 {
|
||||||
_, _ = c.ListMailbox("testbox")
|
t.Fatalf("Got %v headers, want 1", len(headers))
|
||||||
|
}
|
||||||
|
h := headers[0]
|
||||||
|
|
||||||
want = "GET"
|
got := h.Mailbox
|
||||||
got = mth.req.Method
|
want := "testbox"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
t.Errorf("Mailbox got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox"
|
got = h.ID
|
||||||
got = mth.req.URL.String()
|
want = "1"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
t.Errorf("ID got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = h.From
|
||||||
|
want = "fromuser"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("From got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = h.Subject
|
||||||
|
want = "test subject"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Subject got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotTime := h.Date
|
||||||
|
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
|
||||||
|
if !wantTime.Equal(gotTime) {
|
||||||
|
t.Errorf("Date got %v, want %v", gotTime, wantTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotInt := h.Size
|
||||||
|
wantInt := int64(264)
|
||||||
|
if gotInt != wantInt {
|
||||||
|
t.Errorf("Size got %v, want %v", gotInt, wantInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantBool := true
|
||||||
|
gotBool := h.Seen
|
||||||
|
if gotBool != wantBool {
|
||||||
|
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientV1GetMessage(t *testing.T) {
|
func TestClientV1GetMessage(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
messageHandler := &jsonHandler{json: `{
|
||||||
|
"mailbox": "testbox",
|
||||||
|
"id": "20170107T224128-0000",
|
||||||
|
"from": "fromuser",
|
||||||
|
"subject": "test subject",
|
||||||
|
"date": "2013-10-15T16:12:02.231532239-07:00",
|
||||||
|
"size": 264,
|
||||||
|
"seen": true,
|
||||||
|
"body": {
|
||||||
|
"text": "Plain text",
|
||||||
|
"html": "<html>"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
|
||||||
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("GET").Handler(messageHandler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
m, err := c.GetMessage("testbox", "20170107T224128-0000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mth := &mockHTTPClient{}
|
if m == nil {
|
||||||
c.client = mth
|
t.Fatalf("message was nil, wanted a value")
|
||||||
|
|
||||||
// Method under test
|
|
||||||
_, _ = c.GetMessage("testbox", "20170107T224128-0000")
|
|
||||||
|
|
||||||
want = "GET"
|
|
||||||
got = mth.req.Method
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
got := m.Mailbox
|
||||||
got = mth.req.URL.String()
|
want := "testbox"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
t.Errorf("Mailbox got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.ID
|
||||||
|
want = "20170107T224128-0000"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ID got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.From
|
||||||
|
want = "fromuser"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("From got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.Subject
|
||||||
|
want = "test subject"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Subject got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotTime := m.Date
|
||||||
|
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
|
||||||
|
if !wantTime.Equal(gotTime) {
|
||||||
|
t.Errorf("Date got %v, want %v", gotTime, wantTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotInt := m.Size
|
||||||
|
wantInt := int64(264)
|
||||||
|
if gotInt != wantInt {
|
||||||
|
t.Errorf("Size got %v, want %v", gotInt, wantInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotBool := m.Seen
|
||||||
|
wantBool := true
|
||||||
|
if gotBool != wantBool {
|
||||||
|
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.Body.Text
|
||||||
|
want = "Plain text"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Body Text got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = m.Body.HTML
|
||||||
|
want = "<html>"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Body HTML got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientV1MarkSeen(t *testing.T) {
|
func TestClientV1MarkSeen(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
handler := &jsonHandler{}
|
||||||
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("PATCH").
|
||||||
|
Handler(handler)
|
||||||
|
|
||||||
|
// Method under test.
|
||||||
|
err := c.MarkSeen("testbox", "20170107T224128-0000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mth := &mockHTTPClient{}
|
|
||||||
c.client = mth
|
|
||||||
|
|
||||||
// Method under test
|
if !handler.called {
|
||||||
_ = c.MarkSeen("testbox", "20170107T224128-0000")
|
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||||
|
|
||||||
want = "PATCH"
|
|
||||||
got = mth.req.Method
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientV1GetMessageSource(t *testing.T) {
|
func TestClientV1GetMessageSource(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
|
||||||
if err != nil {
|
Handler(&jsonHandler{json: `message source`})
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mth := &mockHTTPClient{
|
|
||||||
body: "message source",
|
|
||||||
}
|
|
||||||
c.client = mth
|
|
||||||
|
|
||||||
// Method under test
|
// Method under test.
|
||||||
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "GET"
|
want := "message source"
|
||||||
got = mth.req.Method
|
got := source.String()
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
t.Errorf("Source got %q, want %q", got, want)
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000/source"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "message source"
|
|
||||||
got = source.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("Source == %q, want: %q", got, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientV1DeleteMessage(t *testing.T) {
|
func TestClientV1DeleteMessage(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
handler := &jsonHandler{}
|
||||||
if err != nil {
|
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("DELETE").
|
||||||
t.Fatal(err)
|
Handler(handler)
|
||||||
}
|
|
||||||
mth := &mockHTTPClient{}
|
|
||||||
c.client = mth
|
|
||||||
|
|
||||||
// Method under test
|
// Method under test.
|
||||||
err = c.DeleteMessage("testbox", "20170107T224128-0000")
|
err := c.DeleteMessage("testbox", "20170107T224128-0000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "DELETE"
|
if !handler.called {
|
||||||
got = mth.req.Method
|
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientV1PurgeMailbox(t *testing.T) {
|
func TestClientV1PurgeMailbox(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
handler := &jsonHandler{}
|
||||||
if err != nil {
|
router.Path("/api/v1/mailbox/testbox").Methods("DELETE").Handler(handler)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mth := &mockHTTPClient{}
|
|
||||||
c.client = mth
|
|
||||||
|
|
||||||
// Method under test
|
// Method under test.
|
||||||
err = c.PurgeMailbox("testbox")
|
err := c.PurgeMailbox("testbox")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "DELETE"
|
if !handler.called {
|
||||||
got = mth.req.Method
|
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientV1MessageHeader(t *testing.T) {
|
func TestClientV1MessageHeader(t *testing.T) {
|
||||||
var want, got string
|
// Setup.
|
||||||
response := `[
|
c, router, teardown := setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
listHandler := &jsonHandler{json: `[
|
||||||
{
|
{
|
||||||
"mailbox":"mailbox1",
|
"mailbox":"mailbox1",
|
||||||
"id":"id1",
|
"id":"id1",
|
||||||
@@ -187,115 +263,52 @@ func TestClientV1MessageHeader(t *testing.T) {
|
|||||||
"size":100,
|
"size":100,
|
||||||
"seen":true
|
"seen":true
|
||||||
}
|
}
|
||||||
]`
|
]`}
|
||||||
|
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||||
|
|
||||||
c, err := New(baseURLStr)
|
// Method under test.
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mth := &mockHTTPClient{body: response}
|
|
||||||
c.client = mth
|
|
||||||
|
|
||||||
// Method under test
|
|
||||||
headers, err := c.ListMailbox("testbox")
|
headers, err := c.ListMailbox("testbox")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "GET"
|
|
||||||
got = mth.req.Method
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/testbox"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(headers) != 1 {
|
if len(headers) != 1 {
|
||||||
t.Fatalf("len(headers) == %v, want 1", len(headers))
|
t.Fatalf("len(headers) == %v, want 1", len(headers))
|
||||||
}
|
}
|
||||||
header := headers[0]
|
header := headers[0]
|
||||||
|
|
||||||
want = "mailbox1"
|
// Test MessageHeader.Delete().
|
||||||
got = header.Mailbox
|
handler := &jsonHandler{}
|
||||||
if got != want {
|
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("DELETE").Handler(handler)
|
||||||
t.Errorf("Mailbox == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "id1"
|
|
||||||
got = header.ID
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("ID == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "from1"
|
|
||||||
got = header.From
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("From == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "subject1"
|
|
||||||
got = header.Subject
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("Subject == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantb := true
|
|
||||||
gotb := header.Seen
|
|
||||||
if gotb != wantb {
|
|
||||||
t.Errorf("Seen == %v, want %v", gotb, wantb)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test MessageHeader.Delete()
|
|
||||||
mth.body = ""
|
|
||||||
err = header.Delete()
|
err = header.Delete()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "DELETE"
|
// Test MessageHeader.GetSource().
|
||||||
got = mth.req.Method
|
router.Path("/api/v1/mailbox/mailbox1/id1/source").Methods("GET").
|
||||||
if got != want {
|
Handler(&jsonHandler{json: `source1`})
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
buf, err := header.GetSource()
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test MessageHeader.GetSource()
|
|
||||||
mth.body = "source1"
|
|
||||||
_, err = header.GetSource()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "GET"
|
want := "source1"
|
||||||
got = mth.req.Method
|
got := buf.String()
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
t.Errorf("Got source %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
|
// Test MessageHeader.GetMessage().
|
||||||
got = mth.req.URL.String()
|
messageHandler := &jsonHandler{json: `{
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test MessageHeader.GetMessage()
|
|
||||||
mth.body = `{
|
|
||||||
"mailbox":"mailbox1",
|
"mailbox":"mailbox1",
|
||||||
"id":"id1",
|
"id":"id1",
|
||||||
"from":"from1",
|
"from":"from1",
|
||||||
"subject":"subject1",
|
"subject":"subject1",
|
||||||
"date":"2017-01-01T00:00:00.000-07:00",
|
"date":"2017-01-01T00:00:00.000-07:00",
|
||||||
"size":100
|
"size":100
|
||||||
}`
|
}`}
|
||||||
|
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("GET").Handler(messageHandler)
|
||||||
message, err := header.GetMessage()
|
message, err := header.GetMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -304,53 +317,45 @@ func TestClientV1MessageHeader(t *testing.T) {
|
|||||||
t.Fatalf("message was nil, wanted a value")
|
t.Fatalf("message was nil, wanted a value")
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "GET"
|
// Test Message.Delete().
|
||||||
got = mth.req.Method
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Message.Delete()
|
|
||||||
mth.body = ""
|
|
||||||
err = message.Delete()
|
err = message.Delete()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "DELETE"
|
// Test Message.GetSource().
|
||||||
got = mth.req.Method
|
buf, err = message.GetSource()
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test MessageHeader.GetSource()
|
|
||||||
mth.body = "source1"
|
|
||||||
_, err = message.GetSource()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want = "GET"
|
want = "source1"
|
||||||
got = mth.req.Method
|
got = buf.String()
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("req.Method == %q, want %q", got, want)
|
t.Errorf("Got source %q, want %q", got, want)
|
||||||
}
|
|
||||||
|
|
||||||
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
|
|
||||||
got = mth.req.URL.String()
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("req.URL == %q, want %q", got, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setup returns a client, router and server for API testing.
|
||||||
|
func setup() (c *client.Client, router *mux.Router, teardown func()) {
|
||||||
|
router = mux.NewRouter()
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
c, err := client.New(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return c, router, func() {
|
||||||
|
server.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonHandler returns the string in json when servicing a request.
|
||||||
|
type jsonHandler struct {
|
||||||
|
json string
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j.called = true
|
||||||
|
w.Write([]byte(j.json))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Example demonstrates basic usage for the Inbucket REST client.
|
||||||
|
func Example() {
|
||||||
|
// Setup a fake Inbucket server for this example.
|
||||||
|
baseURL, teardown := exampleSetup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
|
||||||
|
// `localhost:9000`.
|
||||||
|
restClient, err := client.New(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a slice of message headers for the mailbox named `user1`.
|
||||||
|
headers, err := restClient.ListMailbox("user1")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, header := range headers {
|
||||||
|
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the content of the first message.
|
||||||
|
message, err := headers[0].GetMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nFrom: %v\n", message.From)
|
||||||
|
fmt.Printf("Text body:\n%v", message.Body.Text)
|
||||||
|
|
||||||
|
// Delete the second message.
|
||||||
|
err = headers[1].Delete()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// ID: 20180107T224128-0000, Subject: First subject
|
||||||
|
// ID: 20180108T121212-0123, Subject: Second subject
|
||||||
|
//
|
||||||
|
// From: admin@inbucket.org
|
||||||
|
// Text body:
|
||||||
|
// This is the plain text body
|
||||||
|
}
|
||||||
|
|
||||||
|
// exampleSetup creates a fake Inbucket server to power Example() below.
|
||||||
|
func exampleSetup() (baseURL string, teardown func()) {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
|
||||||
|
// Handle ListMailbox request.
|
||||||
|
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`[
|
||||||
|
{
|
||||||
|
"mailbox": "user1",
|
||||||
|
"id": "20180107T224128-0000",
|
||||||
|
"subject": "First subject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mailbox": "user1",
|
||||||
|
"id": "20180108T121212-0123",
|
||||||
|
"subject": "Second subject"
|
||||||
|
}
|
||||||
|
]`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle GetMessage request.
|
||||||
|
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{
|
||||||
|
"mailbox": "user1",
|
||||||
|
"id": "20180107T224128-0000",
|
||||||
|
"from": "admin@inbucket.org",
|
||||||
|
"subject": "First subject",
|
||||||
|
"body": {
|
||||||
|
"text": "This is the plain text body"
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle Delete request.
|
||||||
|
router.HandleFunc("/api/v1/mailbox/user1/20180108T121212-0123",
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Nop.
|
||||||
|
})
|
||||||
|
|
||||||
|
return server.URL, func() {
|
||||||
|
server.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ func (c *restClient) do(method, uri string, body []byte) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
req, err := http.NewRequest(method, url.String(), r)
|
req, err := http.NewRequest(method, url.String(), r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
|
||||||
}
|
}
|
||||||
return c.client.Do(req)
|
return c.client.Do(req)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
|||||||
return json.NewDecoder(resp.Body).Decode(v)
|
return json.NewDecoder(resp.Body).Decode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
|
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
|
||||||
@@ -77,5 +77,5 @@ func (c *restClient) doJSONBody(method string, uri string, body []byte, v interf
|
|||||||
return json.NewDecoder(resp.Body).Decode(v)
|
return json.NewDecoder(resp.Body).Decode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import (
|
|||||||
|
|
||||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||||
type JSONMessageHeaderV1 struct {
|
type JSONMessageHeaderV1 struct {
|
||||||
Mailbox string `json:"mailbox"`
|
Mailbox string `json:"mailbox"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
To []string `json:"to"`
|
To []string `json:"to"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Size int64 `json:"size"`
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
Seen bool `json:"seen"`
|
Size int64 `json:"size"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
||||||
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
|
|||||||
To []string `json:"to"`
|
To []string `json:"to"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Seen bool `json:"seen"`
|
Seen bool `json:"seen"`
|
||||||
Body *JSONMessageBodyV1 `json:"body"`
|
Body *JSONMessageBodyV1 `json:"body"`
|
||||||
|
|||||||
+9
-9
@@ -1,25 +1,25 @@
|
|||||||
package rest
|
package rest
|
||||||
|
|
||||||
import "github.com/gorilla/mux"
|
import "github.com/gorilla/mux"
|
||||||
import "github.com/jhillyerd/inbucket/pkg/server/web"
|
import "github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
|
||||||
// SetupRoutes populates the routes for the REST interface
|
// SetupRoutes populates the routes for the REST interface
|
||||||
func SetupRoutes(r *mux.Router) {
|
func SetupRoutes(r *mux.Router) {
|
||||||
// API v1
|
// API v1
|
||||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
r.Path("/v1/mailbox/{name}").Handler(
|
||||||
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
|
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
|
||||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
r.Path("/v1/mailbox/{name}").Handler(
|
||||||
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
|
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
|
||||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||||
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
|
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
|
||||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||||
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
|
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
|
||||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||||
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
|
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
|
||||||
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
|
r.Path("/v1/mailbox/{name}/{id}/source").Handler(
|
||||||
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
|
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
|
||||||
r.Path("/api/v1/monitor/messages").Handler(
|
r.Path("/v1/monitor/messages").Handler(
|
||||||
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
||||||
r.Path("/api/v1/monitor/messages/{name}").Handler(
|
r.Path("/v1/monitor/messages/{name}").Handler(
|
||||||
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/rest/model"
|
"github.com/inbucket/inbucket/pkg/rest/model"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,13 +110,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
header := &model.JSONMessageHeaderV1{
|
header := &model.JSONMessageHeaderV1{
|
||||||
Mailbox: msg.Mailbox,
|
Mailbox: msg.Mailbox,
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
From: msg.From,
|
From: msg.From,
|
||||||
To: msg.To,
|
To: msg.To,
|
||||||
Subject: msg.Subject,
|
Subject: msg.Subject,
|
||||||
Date: msg.Date,
|
Date: msg.Date,
|
||||||
Size: msg.Size,
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
|
Size: msg.Size,
|
||||||
}
|
}
|
||||||
if conn.WriteJSON(header) != nil {
|
if conn.WriteJSON(header) != nil {
|
||||||
// Write failed
|
// Write failed
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||||
@@ -43,15 +43,14 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
|||||||
log.SetOutput(buf)
|
log.SetOutput(buf)
|
||||||
|
|
||||||
// Have to reset default mux to prevent duplicate routes
|
// Have to reset default mux to prevent duplicate routes
|
||||||
http.DefaultServeMux = http.NewServeMux()
|
|
||||||
cfg := &config.Root{
|
cfg := &config.Root{
|
||||||
Web: config.Web{
|
Web: config.Web{
|
||||||
UIDir: "../ui",
|
UIDir: "../ui",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
shutdownChan := make(chan bool)
|
shutdownChan := make(chan bool)
|
||||||
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||||
SetupRoutes(web.Router)
|
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
@@ -80,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
|
|||||||
t.Errorf("JSON result%s", msg)
|
t.Errorf("JSON result%s", msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if got, ok := val.(float64); ok {
|
got, ok := val.(float64)
|
||||||
|
if ok {
|
||||||
if got == want {
|
if got == want {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
|
t.Errorf("JSON result/%s == %v (%T) %v (int64),\nwant: %v / %v",
|
||||||
|
path, val, val, int64(got), want, int64(want))
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// not an EOF
|
// not an EOF
|
||||||
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
ssn.logger.Warn().Msgf("Connection error: %v", err)
|
||||||
if netErr, ok := err.(net.Error); ok {
|
if netErr, ok := err.(net.Error); ok {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+120
-79
@@ -3,15 +3,17 @@ package smtp
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/textproto"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -39,7 +41,7 @@ const (
|
|||||||
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
|
||||||
// (?:) is non-grouping sub-match
|
// (?:) is non-grouping sub-match
|
||||||
var fromRegex = regexp.MustCompile(
|
var fromRegex = regexp.MustCompile(
|
||||||
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
|
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
|
||||||
|
|
||||||
func (s State) String() string {
|
func (s State) String() string {
|
||||||
switch s {
|
switch s {
|
||||||
@@ -58,21 +60,22 @@ func (s State) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var commands = map[string]bool{
|
var commands = map[string]bool{
|
||||||
"HELO": true,
|
"HELO": true,
|
||||||
"EHLO": true,
|
"EHLO": true,
|
||||||
"MAIL": true,
|
"MAIL": true,
|
||||||
"RCPT": true,
|
"RCPT": true,
|
||||||
"DATA": true,
|
"DATA": true,
|
||||||
"RSET": true,
|
"RSET": true,
|
||||||
"SEND": true,
|
"SEND": true,
|
||||||
"SOML": true,
|
"SOML": true,
|
||||||
"SAML": true,
|
"SAML": true,
|
||||||
"VRFY": true,
|
"VRFY": true,
|
||||||
"EXPN": true,
|
"EXPN": true,
|
||||||
"HELP": true,
|
"HELP": true,
|
||||||
"NOOP": true,
|
"NOOP": true,
|
||||||
"QUIT": true,
|
"QUIT": true,
|
||||||
"TURN": true,
|
"TURN": true,
|
||||||
|
"STARTTLS": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session holds the state of an SMTP session
|
// Session holds the state of an SMTP session
|
||||||
@@ -89,12 +92,15 @@ type Session struct {
|
|||||||
recipients []*policy.Recipient // Recipients from RCPT commands.
|
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||||
logger zerolog.Logger // Session specific logger.
|
logger zerolog.Logger // Session specific logger.
|
||||||
debug bool // Print network traffic to stdout.
|
debug bool // Print network traffic to stdout.
|
||||||
|
tlsState *tls.ConnectionState
|
||||||
|
text *textproto.Conn
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSession creates a new Session for the given connection
|
// NewSession creates a new Session for the given connection
|
||||||
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||||
reader := bufio.NewReader(conn)
|
reader := bufio.NewReader(conn)
|
||||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||||
|
|
||||||
return &Session{
|
return &Session{
|
||||||
Server: server,
|
Server: server,
|
||||||
id: id,
|
id: id,
|
||||||
@@ -105,6 +111,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
|
|||||||
recipients: make([]*policy.Recipient, 0),
|
recipients: make([]*policy.Recipient, 0),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
debug: server.config.Debug,
|
debug: server.config.Debug,
|
||||||
|
text: textproto.NewConn(conn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +239,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
|||||||
|
|
||||||
// GREET state -> waiting for HELO
|
// GREET state -> waiting for HELO
|
||||||
func (s *Session) greetHandler(cmd string, arg string) {
|
func (s *Session) greetHandler(cmd string, arg string) {
|
||||||
|
const readyBanner = "Great, let's get this show on the road"
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "HELO":
|
case "HELO":
|
||||||
domain, err := parseHelloArgument(arg)
|
domain, err := parseHelloArgument(arg)
|
||||||
@@ -240,7 +248,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.remoteDomain = domain
|
s.remoteDomain = domain
|
||||||
s.send("250 Great, let's get this show on the road")
|
s.send("250 " + readyBanner)
|
||||||
s.enterState(READY)
|
s.enterState(READY)
|
||||||
case "EHLO":
|
case "EHLO":
|
||||||
domain, err := parseHelloArgument(arg)
|
domain, err := parseHelloArgument(arg)
|
||||||
@@ -249,8 +257,12 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.remoteDomain = domain
|
s.remoteDomain = domain
|
||||||
s.send("250-Great, let's get this show on the road")
|
// features before SIZE per RFC
|
||||||
|
s.send("250-" + readyBanner)
|
||||||
s.send("250-8BITMIME")
|
s.send("250-8BITMIME")
|
||||||
|
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
|
||||||
|
s.send("250-STARTTLS")
|
||||||
|
}
|
||||||
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
|
||||||
s.enterState(READY)
|
s.enterState(READY)
|
||||||
default:
|
default:
|
||||||
@@ -271,7 +283,29 @@ func parseHelloArgument(arg string) (string, error) {
|
|||||||
|
|
||||||
// READY state -> waiting for MAIL
|
// READY state -> waiting for MAIL
|
||||||
func (s *Session) readyHandler(cmd string, arg string) {
|
func (s *Session) readyHandler(cmd string, arg string) {
|
||||||
if cmd == "MAIL" {
|
if cmd == "STARTTLS" {
|
||||||
|
if !s.Server.config.TLSEnabled {
|
||||||
|
// invalid command since unconfigured
|
||||||
|
s.logger.Debug().Msgf("454 TLS unavailable on the server")
|
||||||
|
s.send("454 TLS unavailable on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.tlsState != nil {
|
||||||
|
// tls state previously valid
|
||||||
|
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
|
||||||
|
s.send("454 A TLS session already agreed upon.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Debug().Msg("Initiating TLS context.")
|
||||||
|
s.send("220 STARTTLS")
|
||||||
|
// start tls connection handshake
|
||||||
|
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
|
||||||
|
s.conn = tlsConn
|
||||||
|
s.text = textproto.NewConn(s.conn)
|
||||||
|
s.tlsState = new(tls.ConnectionState)
|
||||||
|
*s.tlsState = tlsConn.ConnectionState()
|
||||||
|
s.enterState(GREET)
|
||||||
|
} else if cmd == "MAIL" {
|
||||||
// Capture group 1: from address. 2: optional params.
|
// Capture group 1: from address. 2: optional params.
|
||||||
m := fromRegex.FindStringSubmatch(arg)
|
m := fromRegex.FindStringSubmatch(arg)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
@@ -280,11 +314,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
from := m[1]
|
from := m[1]
|
||||||
if _, _, err := policy.ParseEmailAddress(from); err != nil {
|
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
|
||||||
s.send("501 Bad sender address syntax")
|
s.send("501 Bad sender address syntax")
|
||||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if from == "" {
|
||||||
|
from = "unspecified"
|
||||||
|
}
|
||||||
|
|
||||||
// This is where the client may put BODY=8BITMIME, but we already
|
// This is where the client may put BODY=8BITMIME, but we already
|
||||||
// read the DATA as bytes, so it does not effect our processing.
|
// read the DATA as bytes, so it does not effect our processing.
|
||||||
if m[2] != "" {
|
if m[2] != "" {
|
||||||
@@ -312,6 +350,11 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
|||||||
s.logger.Info().Msgf("Mail from: %v", from)
|
s.logger.Info().Msgf("Mail from: %v", from)
|
||||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||||
s.enterState(MAIL)
|
s.enterState(MAIL)
|
||||||
|
} else if cmd == "EHLO" {
|
||||||
|
// Reset session
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||||
|
s.reset()
|
||||||
|
s.send("250 Session reset")
|
||||||
} else {
|
} else {
|
||||||
s.ooSeq(cmd)
|
s.ooSeq(cmd)
|
||||||
}
|
}
|
||||||
@@ -360,6 +403,12 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
|||||||
}
|
}
|
||||||
s.enterState(DATA)
|
s.enterState(DATA)
|
||||||
return
|
return
|
||||||
|
case "EHLO":
|
||||||
|
// Reset session
|
||||||
|
s.logger.Debug().Msgf("Resetting session state on EHLO request")
|
||||||
|
s.reset()
|
||||||
|
s.send("250 Session reset")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
s.ooSeq(cmd)
|
s.ooSeq(cmd)
|
||||||
}
|
}
|
||||||
@@ -367,57 +416,44 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
|||||||
// DATA
|
// DATA
|
||||||
func (s *Session) dataHandler() {
|
func (s *Session) dataHandler() {
|
||||||
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
||||||
msgBuf := &bytes.Buffer{}
|
msgBuf, err := s.readDataBlock()
|
||||||
for {
|
if err != nil {
|
||||||
lineBuf, err := s.readByteLine()
|
if netErr, ok := err.(net.Error); ok {
|
||||||
if err != nil {
|
if netErr.Timeout() {
|
||||||
if netErr, ok := err.(net.Error); ok {
|
s.send("221 Idle timeout, bye bye")
|
||||||
if netErr.Timeout() {
|
|
||||||
s.send("221 Idle timeout, bye bye")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
s.logger.Warn().Msgf("Error: %v while reading", err)
|
|
||||||
s.enterState(QUIT)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
|
|
||||||
// Mail data complete.
|
|
||||||
tstamp := time.Now().Format(timeStampFormat)
|
|
||||||
for _, recip := range s.recipients {
|
|
||||||
if recip.ShouldStore() {
|
|
||||||
// Generate Received header.
|
|
||||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
|
||||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
|
||||||
tstamp)
|
|
||||||
// Deliver message.
|
|
||||||
_, err := s.manager.Deliver(
|
|
||||||
recip, s.from, s.recipients, prefix, msgBuf.Bytes())
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
|
||||||
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
|
||||||
s.reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expReceivedTotal.Add(1)
|
|
||||||
}
|
|
||||||
s.send("250 Mail accepted for delivery")
|
|
||||||
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
|
|
||||||
s.reset()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// RFC: remove leading periods from DATA.
|
|
||||||
if len(lineBuf) > 0 && lineBuf[0] == '.' {
|
|
||||||
lineBuf = lineBuf[1:]
|
|
||||||
}
|
|
||||||
msgBuf.Write(lineBuf)
|
|
||||||
if msgBuf.Len() > s.config.MaxMessageBytes {
|
|
||||||
s.send("552 Maximum message size exceeded")
|
|
||||||
s.logger.Warn().Msgf("Max message size exceeded while in DATA")
|
|
||||||
s.reset()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||||
|
s.enterState(QUIT)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
mailData := bytes.NewBuffer(msgBuf)
|
||||||
|
|
||||||
|
// Mail data complete.
|
||||||
|
tstamp := time.Now().Format(timeStampFormat)
|
||||||
|
for _, recip := range s.recipients {
|
||||||
|
if recip.ShouldStore() {
|
||||||
|
// Generate Received header.
|
||||||
|
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||||
|
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||||
|
tstamp)
|
||||||
|
|
||||||
|
// Deliver message.
|
||||||
|
_, err := s.manager.Deliver(
|
||||||
|
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||||
|
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||||
|
s.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expReceivedTotal.Add(1)
|
||||||
|
}
|
||||||
|
s.send("250 Mail accepted for delivery")
|
||||||
|
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
|
||||||
|
s.reset()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) enterState(state State) {
|
func (s *Session) enterState(state State) {
|
||||||
@@ -440,7 +476,7 @@ func (s *Session) send(msg string) {
|
|||||||
s.sendError = err
|
s.sendError = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
|
if err := s.text.PrintfLine("%s", msg); err != nil {
|
||||||
s.sendError = err
|
s.sendError = err
|
||||||
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
s.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||||
return
|
return
|
||||||
@@ -450,24 +486,27 @@ func (s *Session) send(msg string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readByteLine reads a line of input, returns byte slice.
|
// readDataBlock reads message DATA until `.` using the textproto pkg.
|
||||||
func (s *Session) readByteLine() ([]byte, error) {
|
func (s *Session) readDataBlock() ([]byte, error) {
|
||||||
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
b, err := s.reader.ReadBytes('\n')
|
b, err := s.text.ReadDotBytes()
|
||||||
if err == nil && s.debug {
|
if err != nil {
|
||||||
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n"))
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.debug {
|
||||||
|
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
|
||||||
}
|
}
|
||||||
return b, err
|
return b, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads a line of input
|
// readLine reads a line of input respecting deadlines.
|
||||||
func (s *Session) readLine() (line string, err error) {
|
func (s *Session) readLine() (line string, err error) {
|
||||||
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
line, err = s.reader.ReadString('\n')
|
line, err = s.text.ReadLine()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -486,19 +525,21 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
|||||||
case l < 4:
|
case l < 4:
|
||||||
s.logger.Warn().Msgf("Command too short: %q", line)
|
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
case l == 4:
|
case l == 4 || l == 8:
|
||||||
return strings.ToUpper(line), "", true
|
return strings.ToUpper(line), "", true
|
||||||
case l == 5:
|
case l == 5:
|
||||||
// Too long to be only command, too short to have args
|
// Too long to be only command, too short to have args
|
||||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we made it here, command is long enough to have args
|
// If we made it here, command is long enough to have args
|
||||||
if line[4] != ' ' {
|
if line[4] != ' ' {
|
||||||
// There wasn't a space after the command?
|
// There wasn't a space after the command?
|
||||||
s.logger.Warn().Msgf("Mangled command: %q", line)
|
s.logger.Warn().Msgf("Mangled command: %q", line)
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// I'm not sure if we should trim the args or not, but we will for now
|
// I'm not sure if we should trim the args or not, but we will for now
|
||||||
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
|
||||||
}
|
}
|
||||||
@@ -513,7 +554,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
|||||||
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
||||||
pm := re.FindAllStringSubmatch(arg, -1)
|
pm := re.FindAllStringSubmatch(arg, -1)
|
||||||
if pm == nil {
|
if pm == nil {
|
||||||
s.logger.Warn().Msgf("Failed to parse arg string: %q")
|
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
for _, m := range pm {
|
for _, m := range pm {
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scriptStep struct {
|
type scriptStep struct {
|
||||||
@@ -79,6 +79,35 @@ func TestGreetState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test commands in READY state
|
||||||
|
func TestEmptyEnvelope(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server, logbuf, teardown := setupSMTPServer(ds)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// Test out some empty envelope without blanks
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM:<>", 250},
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test out some empty envelope with blanks
|
||||||
|
script = []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
{"MAIL FROM: <>", 250},
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
// Dump buffered log data if there was a failure
|
||||||
|
_, _ = io.Copy(os.Stderr, logbuf)
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test commands in READY state
|
// Test commands in READY state
|
||||||
func TestReadyState(t *testing.T) {
|
func TestReadyState(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
@@ -206,6 +235,19 @@ func TestMailState(t *testing.T) {
|
|||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test late EHLO, similar to RSET
|
||||||
|
script = []scriptStep{
|
||||||
|
{"EHLO localhost", 250},
|
||||||
|
{"EHLO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
{"RCPT TO:<u1@gmail.com>", 250},
|
||||||
|
{"EHLO localhost", 250},
|
||||||
|
{"MAIL FROM:<john@gmail.com>", 250},
|
||||||
|
}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test RSET
|
// Test RSET
|
||||||
script = []scriptStep{
|
script = []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ package smtp
|
|||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"expvar"
|
"expvar"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
"github.com/inbucket/inbucket/pkg/metric"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ type Server struct {
|
|||||||
manager message.Manager // Used to deliver messages.
|
manager message.Manager // Used to deliver messages.
|
||||||
listener net.Listener // Incoming network connections.
|
listener net.Listener // Incoming network connections.
|
||||||
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||||
|
tlsConfig *tls.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Server instance with the specificed config.
|
// NewServer creates a new Server instance with the specificed config.
|
||||||
@@ -72,12 +74,28 @@ func NewServer(
|
|||||||
manager message.Manager,
|
manager message.Manager,
|
||||||
apolicy *policy.Addressing,
|
apolicy *policy.Addressing,
|
||||||
) *Server {
|
) *Server {
|
||||||
|
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
|
||||||
|
tlsConfig := &tls.Config{}
|
||||||
|
if smtpConfig.TLSEnabled {
|
||||||
|
var err error
|
||||||
|
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
||||||
|
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
|
||||||
|
slog.Error().Msg("Disabling STARTTLS support")
|
||||||
|
smtpConfig.TLSEnabled = false
|
||||||
|
} else {
|
||||||
|
slog.Debug().Msg("STARTTLS feature available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
config: smtpConfig,
|
config: smtpConfig,
|
||||||
globalShutdown: globalShutdown,
|
globalShutdown: globalShutdown,
|
||||||
manager: manager,
|
manager: manager,
|
||||||
addrPolicy: apolicy,
|
addrPolicy: apolicy,
|
||||||
wg: new(sync.WaitGroup),
|
wg: new(sync.WaitGroup),
|
||||||
|
tlsConfig: tlsConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
type jsonAppConfig struct {
|
||||||
|
BasePath string `json:"base-path"`
|
||||||
|
MonitorVisible bool `json:"monitor-visible"`
|
||||||
|
}
|
||||||
@@ -5,17 +5,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Context is passed into every request handler function
|
// Context is passed into every request handler function
|
||||||
// TODO remove redundant web config
|
// TODO remove redundant web config
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
Session *sessions.Session
|
|
||||||
MsgHub *msghub.Hub
|
MsgHub *msghub.Hub
|
||||||
Manager message.Manager
|
Manager message.Manager
|
||||||
RootConfig *config.Root
|
RootConfig *config.Root
|
||||||
@@ -48,24 +46,13 @@ func headerMatch(req *http.Request, name string, value string) bool {
|
|||||||
// NewContext returns a Context for the given HTTP Request
|
// NewContext returns a Context for the given HTTP Request
|
||||||
func NewContext(req *http.Request) (*Context, error) {
|
func NewContext(req *http.Request) (*Context, error) {
|
||||||
vars := mux.Vars(req)
|
vars := mux.Vars(req)
|
||||||
sess, err := sessionStore.Get(req, "inbucket")
|
|
||||||
if err != nil {
|
|
||||||
if sess == nil {
|
|
||||||
// No session, must fail
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// The session cookie was probably signed by an old key, ignore it
|
|
||||||
// gorilla created an empty session for us
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
Vars: vars,
|
Vars: vars,
|
||||||
Session: sess,
|
|
||||||
MsgHub: msgHub,
|
MsgHub: msgHub,
|
||||||
Manager: manager,
|
Manager: manager,
|
||||||
RootConfig: rootConfig,
|
RootConfig: rootConfig,
|
||||||
WebConfig: rootConfig.Web,
|
WebConfig: rootConfig.Web,
|
||||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||||
}
|
}
|
||||||
return ctx, err
|
return ctx, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a function type that handles an HTTP request in Inbucket.
|
||||||
|
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||||
|
|
||||||
|
// ServeHTTP builds the context and passes onto the real handler.
|
||||||
|
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Create the context.
|
||||||
|
ctx, err := NewContext(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ctx.Close()
|
||||||
|
|
||||||
|
// Run the handler, grab the error, and report it.
|
||||||
|
err = h(w, req, ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
||||||
|
Msg("Error handling request")
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieHandler injects an HTTP cookie into the response.
|
||||||
|
func cookieHandler(cookie *http.Cookie, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg("Injecting cookie")
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
next.ServeHTTP(w, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileHandler creates a handler that sends the named file regardless of the requested URL.
|
||||||
|
func fileHandler(name string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
f, err := os.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||||
|
Msg("Error opening file")
|
||||||
|
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
d, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
|
||||||
|
Msg("Error stating file")
|
||||||
|
http.Error(w, "Error opening file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeContent(w, req, d.Name(), d.ModTime(), f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
|
||||||
|
// returning specified statusCode to the client.
|
||||||
|
func noMatchHandler(statusCode int, message string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Warn().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg(message)
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestLoggingWrapper returns middleware that logs client requests.
|
||||||
|
func requestLoggingWrapper(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
||||||
|
next.ServeHTTP(w, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
|
||||||
|
func spaTemplateHandler(tmpl *template.Template, basePath string,
|
||||||
|
webConfig config.Web) http.Handler {
|
||||||
|
tmplData := struct {
|
||||||
|
BasePath string
|
||||||
|
}{
|
||||||
|
BasePath: basePath,
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
err := tmpl.Execute(w, tmplData)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
||||||
|
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
|
||||||
|
Msg("Error rendering SPA index template")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,60 +3,20 @@ package web
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
|
||||||
var TemplateFuncs = template.FuncMap{
|
|
||||||
"friendlyTime": FriendlyTime,
|
|
||||||
"reverse": Reverse,
|
|
||||||
"stringsJoin": strings.Join,
|
|
||||||
"textToHtml": TextToHTML,
|
|
||||||
}
|
|
||||||
|
|
||||||
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
// 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`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
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.Error().Str("module", "web").Str("name", name).Err(err).
|
|
||||||
Msg("Failed to reverse route")
|
|
||||||
return "/ROUTE-ERROR"
|
|
||||||
}
|
|
||||||
return u.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||||
// HTML display
|
// HTML display
|
||||||
func TextToHTML(text string) template.HTML {
|
func TextToHTML(text string) string {
|
||||||
text = html.EscapeString(text)
|
text = html.EscapeString(text)
|
||||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||||
return template.HTML(replacer.Replace(text))
|
return replacer.Replace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapURL wraps a <a href> tag around the provided URL
|
// WrapURL wraps a <a href> tag around the provided URL
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTextToHtml(t *testing.T) {
|
func TestTextToHtml(t *testing.T) {
|
||||||
// Identity
|
testCases := []struct {
|
||||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
input, want string
|
||||||
|
}{
|
||||||
// Check it escapes
|
{
|
||||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
input: "html",
|
||||||
|
want: "html",
|
||||||
// Check for linebreaks
|
},
|
||||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
// Check it escapes.
|
||||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
{
|
||||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
input: "<html>",
|
||||||
}
|
want: "<html>",
|
||||||
|
},
|
||||||
func TestURLDetection(t *testing.T) {
|
// Check for linebreaks.
|
||||||
assert.Equal(t,
|
{
|
||||||
TextToHTML("http://google.com/"),
|
input: "line\nbreak",
|
||||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
want: "line<br/>\nbreak",
|
||||||
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>"))
|
input: "line\r\nbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "line\rbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
// Check URL detection.
|
||||||
|
{
|
||||||
|
input: "http://google.com/",
|
||||||
|
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "http://a.com/?q=a&n=v",
|
||||||
|
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "(http://a.com/?q=a&n=v)",
|
||||||
|
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
got := TextToHTML(tc.input)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-55
@@ -3,29 +3,25 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"expvar"
|
"expvar"
|
||||||
|
"html/template"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/gorilla/securecookie"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/jhillyerd/inbucket/pkg/msghub"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is a function type that handles an HTTP request in Inbucket
|
|
||||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
|
||||||
|
|
||||||
const (
|
|
||||||
staticDir = "static"
|
|
||||||
templateDir = "templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// msgHub holds a reference to the message pub/sub system
|
// msgHub holds a reference to the message pub/sub system
|
||||||
msgHub *msghub.Hub
|
msgHub *msghub.Hub
|
||||||
@@ -38,7 +34,6 @@ var (
|
|||||||
rootConfig *config.Root
|
rootConfig *config.Root
|
||||||
server *http.Server
|
server *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
sessionStore sessions.Store
|
|
||||||
globalShutdown chan bool
|
globalShutdown chan bool
|
||||||
|
|
||||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||||
@@ -50,7 +45,7 @@ func init() {
|
|||||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sets up things for unit tests or the Start() method
|
// Initialize sets up things for unit tests or the Start() method.
|
||||||
func Initialize(
|
func Initialize(
|
||||||
conf *config.Root,
|
conf *config.Root,
|
||||||
shutdownChan chan bool,
|
shutdownChan chan bool,
|
||||||
@@ -60,35 +55,76 @@ func Initialize(
|
|||||||
rootConfig = conf
|
rootConfig = conf
|
||||||
globalShutdown = shutdownChan
|
globalShutdown = shutdownChan
|
||||||
|
|
||||||
// NewContext() will use this DataStore for the web handlers
|
// NewContext() will use this DataStore for the web handlers.
|
||||||
msgHub = mh
|
msgHub = mh
|
||||||
manager = mm
|
manager = mm
|
||||||
|
|
||||||
// Content Paths
|
// Redirect requests to / if there is a base path configured.
|
||||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
|
||||||
|
redirectBase := prefix("/")
|
||||||
|
if redirectBase != "/" {
|
||||||
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
|
||||||
|
Msg("Base path configured")
|
||||||
|
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic paths.
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
|
||||||
Msg("Web UI content mapped")
|
Msg("Web UI content mapped")
|
||||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
Router.Handle(prefix("/debug/vars"), expvar.Handler())
|
||||||
http.FileServer(http.Dir(staticPath))))
|
if conf.Web.PProf {
|
||||||
http.Handle("/", Router)
|
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
|
||||||
|
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
|
||||||
// Session cookie setup
|
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
|
||||||
if conf.Web.CookieAuthKey == "" {
|
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").
|
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
|
||||||
Msg("Generating random cookie.auth.key")
|
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
|
||||||
} else {
|
|
||||||
log.Info().Str("module", "web").Str("phase", "startup").
|
|
||||||
Msg("Using configured cookie.auth.key")
|
|
||||||
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static paths.
|
||||||
|
Router.PathPrefix(prefix("/static")).Handler(
|
||||||
|
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||||
|
Router.Path(prefix("/favicon.png")).Handler(
|
||||||
|
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
|
||||||
|
|
||||||
|
// Parse index.html template, allowing for configuration to be passed to the SPA.
|
||||||
|
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
|
||||||
|
indexTmpl, err := template.ParseFiles(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
msg := "Failed to parse HTML template"
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
log.Error().
|
||||||
|
Str("module", "web").
|
||||||
|
Str("phase", "startup").
|
||||||
|
Str("path", indexPath).
|
||||||
|
Str("cwd", cwd).
|
||||||
|
Err(err).
|
||||||
|
Msg(msg)
|
||||||
|
// Create a dummy template to allow tests to pass.
|
||||||
|
indexTmpl, _ = template.New("index.html").Parse(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA managed paths.
|
||||||
|
spaHandler := cookieHandler(appConfigCookie(conf.Web),
|
||||||
|
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
|
||||||
|
Router.Path(prefix("/")).Handler(spaHandler)
|
||||||
|
Router.Path(prefix("/monitor")).Handler(spaHandler)
|
||||||
|
Router.Path(prefix("/status")).Handler(spaHandler)
|
||||||
|
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
|
||||||
|
|
||||||
|
// Error handlers.
|
||||||
|
Router.NotFoundHandler = noMatchHandler(
|
||||||
|
http.StatusNotFound, "No route matches URI path")
|
||||||
|
Router.MethodNotAllowedHandler = noMatchHandler(
|
||||||
|
http.StatusMethodNotAllowed, "Method not allowed for URI path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins listening for HTTP requests
|
// Start begins listening for HTTP requests
|
||||||
func Start(ctx context.Context) {
|
func Start(ctx context.Context) {
|
||||||
server = &http.Server{
|
server = &http.Server{
|
||||||
Addr: rootConfig.Web.Addr,
|
Addr: rootConfig.Web.Addr,
|
||||||
Handler: nil,
|
Handler: requestLoggingWrapper(Router),
|
||||||
ReadTimeout: 60 * time.Second,
|
ReadTimeout: 60 * time.Second,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -122,6 +158,23 @@ func Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appConfigCookie(webConfig config.Web) *http.Cookie {
|
||||||
|
o := &jsonAppConfig{
|
||||||
|
BasePath: webConfig.BasePath,
|
||||||
|
MonitorVisible: webConfig.MonitorVisible,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(o)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
|
||||||
|
Msg("Failed to convert app-config to JSON")
|
||||||
|
}
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: "app-config",
|
||||||
|
Value: url.PathEscape(string(b)),
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// serve begins serving HTTP requests
|
// serve begins serving HTTP requests
|
||||||
func serve(ctx context.Context) {
|
func serve(ctx context.Context) {
|
||||||
// server.Serve blocks until we close the listener
|
// server.Serve blocks until we close the listener
|
||||||
@@ -138,29 +191,6 @@ func serve(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP builds the context and passes onto the real handler
|
|
||||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
||||||
// Create the context
|
|
||||||
ctx, err := NewContext(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer ctx.Close()
|
|
||||||
|
|
||||||
// Run the handler, grab the error, and report it
|
|
||||||
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
|
|
||||||
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
|
|
||||||
err = h(w, req, ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
|
|
||||||
Msg("Error handling request")
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func emergencyShutdown() {
|
func emergencyShutdown() {
|
||||||
// Shutdown Inbucket
|
// Shutdown Inbucket
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/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.Error().Str("module", "web").Str("path", name).Err(err).
|
|
||||||
Msg("Error in template")
|
|
||||||
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.Error().Str("module", "web").Str("path", name).Err(err).
|
|
||||||
Msg("Error in template")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
|
|
||||||
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
|
|
||||||
|
|
||||||
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(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allows us to disable caching for theme development
|
|
||||||
if rootConfig.Web.TemplateCache {
|
|
||||||
if partial {
|
|
||||||
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
|
|
||||||
cachedTemplates[name] = t
|
|
||||||
} else {
|
|
||||||
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
|
|
||||||
cachedTemplates[name] = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
+61
-38
@@ -4,14 +4,14 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,10 +40,11 @@ func countGenerator(c chan int) {
|
|||||||
// Store implements DataStore aand is the root of the mail storage
|
// Store implements DataStore aand is the root of the mail storage
|
||||||
// hiearchy. It provides access to Mailbox objects
|
// hiearchy. It provides access to Mailbox objects
|
||||||
type Store struct {
|
type Store struct {
|
||||||
hashLock storage.HashLock
|
hashLock storage.HashLock
|
||||||
path string
|
path string
|
||||||
mailPath string
|
mailPath string
|
||||||
messageCap int
|
messageCap int
|
||||||
|
bufReaderPool sync.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new DataStore object using the specified path
|
// New creates a new DataStore object using the specified path
|
||||||
@@ -60,7 +61,16 @@ func New(cfg config.Storage) (storage.Store, error) {
|
|||||||
Msg("Error creating dir")
|
Msg("Error creating dir")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &Store{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}, nil
|
return &Store{
|
||||||
|
path: path,
|
||||||
|
mailPath: mailPath,
|
||||||
|
messageCap: cfg.MailboxMsgCap,
|
||||||
|
bufReaderPool: sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return bufio.NewReader(nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddMessage adds a message to the specified mailbox.
|
// AddMessage adds a message to the specified mailbox.
|
||||||
@@ -179,41 +189,33 @@ func (fs *Store) PurgeMessages(mailbox string) error {
|
|||||||
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
||||||
// continues to return true.
|
// continues to return true.
|
||||||
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
||||||
infos1, err := ioutil.ReadDir(fs.mailPath)
|
names1, err := readDirNames(fs.mailPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Loop over level 1 directories
|
// Loop over level 1 directories
|
||||||
for _, inf1 := range infos1 {
|
for _, name1 := range names1 {
|
||||||
if inf1.IsDir() {
|
names2, err := readDirNames(fs.mailPath, name1)
|
||||||
l1 := inf1.Name()
|
if err != nil {
|
||||||
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
|
return err
|
||||||
|
}
|
||||||
|
// Loop over level 2 directories
|
||||||
|
for _, name2 := range names2 {
|
||||||
|
names3, err := readDirNames(fs.mailPath, name1, name2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Loop over level 2 directories
|
// Loop over mailboxes
|
||||||
for _, inf2 := range infos2 {
|
for _, name3 := range names3 {
|
||||||
if inf2.IsDir() {
|
mb := fs.mboxFromHash(name3)
|
||||||
l2 := inf2.Name()
|
mb.RLock()
|
||||||
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
|
msgs, err := mb.getMessages()
|
||||||
if err != nil {
|
mb.RUnlock()
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
// Loop over mailboxes
|
}
|
||||||
for _, inf3 := range infos3 {
|
if !f(msgs) {
|
||||||
if inf3.IsDir() {
|
return nil
|
||||||
mb := fs.mboxFromHash(inf3.Name())
|
|
||||||
mb.RLock()
|
|
||||||
msgs, err := mb.getMessages()
|
|
||||||
mb.RUnlock()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !f(msgs) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +255,18 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPooledReader pulls a buffered reader from the fs.bufReaderPool.
|
||||||
|
func (fs *Store) getPooledReader(r io.Reader) *bufio.Reader {
|
||||||
|
br := fs.bufReaderPool.Get().(*bufio.Reader)
|
||||||
|
br.Reset(r)
|
||||||
|
return br
|
||||||
|
}
|
||||||
|
|
||||||
|
// putPooledReader returns a buffered reader to the fs.bufReaderPool.
|
||||||
|
func (fs *Store) putPooledReader(br *bufio.Reader) {
|
||||||
|
fs.bufReaderPool.Put(br)
|
||||||
|
}
|
||||||
|
|
||||||
// generatePrefix converts a Time object into the ISO style format we use
|
// 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
|
// as a prefix for message files. Note: It is used directly by unit
|
||||||
// tests.
|
// tests.
|
||||||
@@ -261,7 +275,16 @@ func generatePrefix(date time.Time) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// generateId adds a 4-digit unique number onto the end of the string
|
// generateId adds a 4-digit unique number onto the end of the string
|
||||||
// returned by generatePrefix()
|
// returned by generatePrefix().
|
||||||
func generateID(date time.Time) string {
|
func generateID(date time.Time) string {
|
||||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readDirNames returns a slice of filenames in the specified directory or an error.
|
||||||
|
func readDirNames(elem ...string) ([]string, error) {
|
||||||
|
f, err := os.Open(filepath.Join(elem...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.Readdirnames(0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,7 +120,9 @@ func (mb *mbox) readIndex() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// Decode gob data
|
// Decode gob data
|
||||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
br := mb.store.getPooledReader(file)
|
||||||
|
defer mb.store.putPooledReader(br)
|
||||||
|
dec := gob.NewDecoder(br)
|
||||||
name := ""
|
name := ""
|
||||||
if err = dec.Decode(&name); err != nil {
|
if err = dec.Decode(&name); err != nil {
|
||||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package storage_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashLock(t *testing.T) {
|
func TestHashLock(t *testing.T) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message is a memory store message.
|
// Message is a memory store message.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store implements an in-memory message store.
|
// Store implements an in-memory message store.
|
||||||
@@ -92,6 +92,17 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
|
|||||||
|
|
||||||
// GetMessage gets a mesage.
|
// GetMessage gets a mesage.
|
||||||
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
|
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
|
||||||
|
if id == "latest" {
|
||||||
|
ms, err := s.GetMessages(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := len(ms)
|
||||||
|
if count == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return ms[count-1], nil
|
||||||
|
}
|
||||||
s.withMailbox(mailbox, false, func(mb *mbox) {
|
s.withMailbox(mailbox, false, func(mb *mbox) {
|
||||||
var ok bool
|
var ok bool
|
||||||
m, ok = mb.messages[id]
|
m, ok = mb.messages[id]
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSuite runs storage package test suite on file store.
|
// TestSuite runs storage package test suite on file store.
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"expvar"
|
"expvar"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/metric"
|
"github.com/inbucket/inbucket/pkg/metric"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
retentionScanCompleted = time.Now()
|
scanCompletedMillis = new(expvar.Int)
|
||||||
retentionScanCompletedMu sync.RWMutex
|
|
||||||
|
|
||||||
// History counters
|
// History counters
|
||||||
expRetentionDeletesTotal = new(expvar.Int)
|
expRetentionDeletesTotal = new(expvar.Int)
|
||||||
@@ -34,7 +32,7 @@ var (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rm := expvar.NewMap("retention")
|
rm := expvar.NewMap("retention")
|
||||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
rm.Set("ScanCompletedMillis", scanCompletedMillis)
|
||||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
rm.Set("DeletesHist", expRetentionDeletesHist)
|
||||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
||||||
rm.Set("Period", expRetentionPeriod)
|
rm.Set("Period", expRetentionPeriod)
|
||||||
@@ -159,7 +157,7 @@ func (rs *RetentionScanner) DoScan() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Update metrics
|
// Update metrics
|
||||||
setRetentionScanCompleted(time.Now())
|
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
|
||||||
expRetainedCurrent.Set(int64(retained))
|
expRetainedCurrent.Set(int64(retained))
|
||||||
expRetainedSize.Set(storeSize)
|
expRetainedSize.Set(storeSize)
|
||||||
return nil
|
return nil
|
||||||
@@ -171,19 +169,3 @@ func (rs *RetentionScanner) Join() {
|
|||||||
<-rs.retentionShutdown
|
<-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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/test"
|
"github.com/inbucket/inbucket/pkg/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDoRetentionScan(t *testing.T) {
|
func TestDoRetentionScan(t *testing.T) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
+32
-4
@@ -19,13 +19,28 @@ func HashMailboxName(mailbox string) string {
|
|||||||
return fmt.Sprintf("%x", h.Sum(nil))
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// StringAddressList converts a list of addresses to a list of strings
|
// StringAddress converts an Address to a UTF-8 string.
|
||||||
|
func StringAddress(a *mail.Address) string {
|
||||||
|
b := &strings.Builder{}
|
||||||
|
if a != nil {
|
||||||
|
if a.Name != "" {
|
||||||
|
b.WriteString(a.Name)
|
||||||
|
b.WriteRune(' ')
|
||||||
|
}
|
||||||
|
if a.Address != "" {
|
||||||
|
b.WriteRune('<')
|
||||||
|
b.WriteString(a.Address)
|
||||||
|
b.WriteRune('>')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringAddressList converts a list of addresses to a list of UTF-8 strings.
|
||||||
func StringAddressList(addrs []*mail.Address) []string {
|
func StringAddressList(addrs []*mail.Address) []string {
|
||||||
s := make([]string, len(addrs))
|
s := make([]string, len(addrs))
|
||||||
for i, a := range addrs {
|
for i, a := range addrs {
|
||||||
if a != nil {
|
s[i] = StringAddress(a)
|
||||||
s[i] = a.String()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -46,3 +61,16 @@ func SliceToLower(slice []string) {
|
|||||||
slice[i] = strings.ToLower(s)
|
slice[i] = strings.ToLower(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings.
|
||||||
|
// The returned prefixer expects all provided paths to start with /.
|
||||||
|
func MakePathPrefixer(prefix string) func(string) string {
|
||||||
|
prefix = strings.Trim(prefix, "/")
|
||||||
|
if prefix != "" {
|
||||||
|
prefix = "/" + prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(path string) string {
|
||||||
|
return prefix + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package stringutil_test
|
package stringutil_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHashMailboxName(t *testing.T) {
|
func TestHashMailboxName(t *testing.T) {
|
||||||
@@ -17,10 +18,14 @@ func TestHashMailboxName(t *testing.T) {
|
|||||||
|
|
||||||
func TestStringAddressList(t *testing.T) {
|
func TestStringAddressList(t *testing.T) {
|
||||||
input := []*mail.Address{
|
input := []*mail.Address{
|
||||||
{Name: "Fred B. Fish", Address: "fred@fish.org"},
|
{Name: "Fred ß. Fish", Address: "fred@fish.org"},
|
||||||
{Name: "User", Address: "user@domain.org"},
|
{Name: "User", Address: "user@domain.org"},
|
||||||
|
{Address: "a@b.com"},
|
||||||
}
|
}
|
||||||
want := []string{`"Fred B. Fish" <fred@fish.org>`, `"User" <user@domain.org>`}
|
want := []string{
|
||||||
|
`Fred ß. Fish <fred@fish.org>`,
|
||||||
|
`User <user@domain.org>`,
|
||||||
|
`<a@b.com>`}
|
||||||
output := stringutil.StringAddressList(input)
|
output := stringutil.StringAddressList(input)
|
||||||
if len(output) != len(want) {
|
if len(output) != len(want) {
|
||||||
t.Fatalf("Got %v strings, want: %v", len(output), len(want))
|
t.Fatalf("Got %v strings, want: %v", len(output), len(want))
|
||||||
@@ -31,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMakePathPrefixer(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
prefix, path, want string
|
||||||
|
}{
|
||||||
|
{prefix: "", path: "", want: ""},
|
||||||
|
{prefix: "", path: "relative", want: "relative"},
|
||||||
|
{prefix: "", path: "/qualified", want: "/qualified"},
|
||||||
|
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
|
||||||
|
{prefix: "pfx", path: "", want: "/pfx"},
|
||||||
|
{prefix: "pfx", path: "/", want: "/pfx/"},
|
||||||
|
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
|
||||||
|
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
|
||||||
|
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||||
|
{prefix: "/pfx/", path: "", want: "/pfx"},
|
||||||
|
{prefix: "/pfx/", path: "/", want: "/pfx/"},
|
||||||
|
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
|
||||||
|
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
|
||||||
|
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
|
||||||
|
{prefix: "a/b/c", path: "", want: "/a/b/c"},
|
||||||
|
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
|
||||||
|
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
|
||||||
|
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
|
||||||
|
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||||
|
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
|
||||||
|
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
|
||||||
|
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
|
||||||
|
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
|
||||||
|
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
|
||||||
|
prefixer := stringutil.MakePathPrefixer(tc.prefix)
|
||||||
|
got := prefixer(tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Got: %q, want: %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
smtpclient "net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
|
"github.com/inbucket/inbucket/pkg/msghub"
|
||||||
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest"
|
||||||
|
"github.com/inbucket/inbucket/pkg/rest/client"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/smtp"
|
||||||
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
|
"github.com/inbucket/inbucket/pkg/storage/mem"
|
||||||
|
"github.com/inbucket/inbucket/pkg/webui"
|
||||||
|
"github.com/jhillyerd/goldiff"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
restBaseURL = "http://127.0.0.1:9000/"
|
||||||
|
smtpHost = "127.0.0.1:2500"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSuite(t *testing.T) {
|
||||||
|
stopServer, err := startServer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer stopServer()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
test func(*testing.T)
|
||||||
|
}{
|
||||||
|
{"basic", testBasic},
|
||||||
|
{"fullname", testFullname},
|
||||||
|
{"encodedHeader", testEncodedHeader},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, tc.test)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBasic(t *testing.T) {
|
||||||
|
client, err := client.New(restBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
from := "fromuser@inbucket.org"
|
||||||
|
to := []string{"recipient@inbucket.org"}
|
||||||
|
input := readTestData("basic.txt")
|
||||||
|
|
||||||
|
// Send mail.
|
||||||
|
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm receipt.
|
||||||
|
msg, err := client.GetMessage("recipient", "latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
t.Errorf("Got nil message, wanted non-nil message.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare to golden.
|
||||||
|
got := formatMessage(msg)
|
||||||
|
goldiff.File(t, got, "testdata", "basic.golden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullname(t *testing.T) {
|
||||||
|
client, err := client.New(restBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
from := "fromuser@inbucket.org"
|
||||||
|
to := []string{"recipient@inbucket.org"}
|
||||||
|
input := readTestData("fullname.txt")
|
||||||
|
|
||||||
|
// Send mail.
|
||||||
|
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm receipt.
|
||||||
|
msg, err := client.GetMessage("recipient", "latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
t.Errorf("Got nil message, wanted non-nil message.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare to golden.
|
||||||
|
got := formatMessage(msg)
|
||||||
|
goldiff.File(t, got, "testdata", "fullname.golden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEncodedHeader(t *testing.T) {
|
||||||
|
client, err := client.New(restBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
from := "fromuser@inbucket.org"
|
||||||
|
to := []string{"recipient@inbucket.org"}
|
||||||
|
input := readTestData("encodedheader.txt")
|
||||||
|
|
||||||
|
// Send mail.
|
||||||
|
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm receipt.
|
||||||
|
msg, err := client.GetMessage("recipient", "latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
t.Errorf("Got nil message, wanted non-nil message.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare to golden.
|
||||||
|
got := formatMessage(msg)
|
||||||
|
goldiff.File(t, got, "testdata", "encodedheader.golden")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessage(m *client.Message) []byte {
|
||||||
|
b := &bytes.Buffer{}
|
||||||
|
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
|
||||||
|
fmt.Fprintf(b, "From: %v\n", m.From)
|
||||||
|
fmt.Fprintf(b, "To: %v\n", m.To)
|
||||||
|
fmt.Fprintf(b, "Subject: %v\n", m.Subject)
|
||||||
|
fmt.Fprintf(b, "Size: %v\n", m.Size)
|
||||||
|
fmt.Fprintf(b, "\nBODY TEXT:\n%v\n", m.Body.Text)
|
||||||
|
fmt.Fprintf(b, "\nBODY HTML:\n%v\n", m.Body.HTML)
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer() (func(), error) {
|
||||||
|
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
|
||||||
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||||
|
storage.Constructors["memory"] = mem.New
|
||||||
|
os.Clearenv()
|
||||||
|
conf, err := config.Process()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||||
|
shutdownChan := make(chan bool)
|
||||||
|
store, err := storage.FromConfig(conf.Storage)
|
||||||
|
if err != nil {
|
||||||
|
rootCancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
|
||||||
|
addrPolicy := &policy.Addressing{Config: conf}
|
||||||
|
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
|
||||||
|
// Start HTTP server.
|
||||||
|
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||||
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
|
go web.Start(rootCtx)
|
||||||
|
// Start SMTP server.
|
||||||
|
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
|
||||||
|
go smtpServer.Start(rootCtx)
|
||||||
|
|
||||||
|
// TODO Implmement an elegant way to determine server readiness.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
// Shut everything down.
|
||||||
|
close(shutdownChan)
|
||||||
|
rootCancel()
|
||||||
|
smtpServer.Drain()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTestData(path ...string) []byte {
|
||||||
|
// Prefix path with testdata.
|
||||||
|
p := append([]string{"testdata"}, path...)
|
||||||
|
f, err := os.Open(filepath.Join(p...))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
data, err := ioutil.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
+4
-4
@@ -3,10 +3,10 @@ package test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/policy"
|
"github.com/inbucket/inbucket/pkg/policy"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManagerStub is a test stub for message.Manager
|
// ManagerStub is a test stub for message.Manager
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ package test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreStub stubs storage.Store for testing.
|
// StoreStub stubs storage.Store for testing.
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/message"
|
"github.com/inbucket/inbucket/pkg/message"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreFactory returns a new store for the test suite.
|
// StoreFactory returns a new store for the test suite.
|
||||||
@@ -27,6 +27,7 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
|
|||||||
{"metadata", testMetadata, config.Storage{}},
|
{"metadata", testMetadata, config.Storage{}},
|
||||||
{"content", testContent, config.Storage{}},
|
{"content", testContent, config.Storage{}},
|
||||||
{"delivery order", testDeliveryOrder, config.Storage{}},
|
{"delivery order", testDeliveryOrder, config.Storage{}},
|
||||||
|
{"latest", testLatest, config.Storage{}},
|
||||||
{"naming", testNaming, config.Storage{}},
|
{"naming", testNaming, config.Storage{}},
|
||||||
{"size", testSize, config.Storage{}},
|
{"size", testSize, config.Storage{}},
|
||||||
{"seen", testSeen, config.Storage{}},
|
{"seen", testSeen, config.Storage{}},
|
||||||
@@ -192,6 +193,29 @@ func testDeliveryOrder(t *testing.T, store storage.Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
|
||||||
|
// the last message sent.
|
||||||
|
func testLatest(t *testing.T, store storage.Store) {
|
||||||
|
mailbox := "fred"
|
||||||
|
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
|
||||||
|
for _, subj := range subjects {
|
||||||
|
DeliverToStore(t, store, mailbox, subj, time.Now())
|
||||||
|
}
|
||||||
|
// Confirm latest.
|
||||||
|
latest, err := store.GetMessage(mailbox, "latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if latest == nil {
|
||||||
|
t.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
|
||||||
|
}
|
||||||
|
got := latest.Subject()
|
||||||
|
want := "echo"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Got subject %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// testNaming ensures the store does not enforce local part mailbox naming.
|
// testNaming ensures the store does not enforce local part mailbox naming.
|
||||||
func testNaming(t *testing.T, store storage.Store) {
|
func testNaming(t *testing.T, store storage.Store) {
|
||||||
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
|
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
|
||||||
@@ -199,7 +223,7 @@ func testNaming(t *testing.T, store storage.Store) {
|
|||||||
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
GetAndCountMessages(t, store, "fred@fish.net", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// testSize verifies message contnet size metadata values.
|
// testSize verifies message content size metadata values.
|
||||||
func testSize(t *testing.T, store storage.Store) {
|
func testSize(t *testing.T, store storage.Store) {
|
||||||
mailbox := "fred"
|
mailbox := "fred"
|
||||||
subjects := []string{"a", "br", "much longer than the others"}
|
subjects := []string{"a", "br", "much longer than the others"}
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Mailbox: recipient
|
||||||
|
From: <fromuser@inbucket.org>
|
||||||
|
To: [<recipient@inbucket.org>]
|
||||||
|
Subject: basic subject
|
||||||
|
Size: 217
|
||||||
|
|
||||||
|
BODY TEXT:
|
||||||
|
Basic message.
|
||||||
|
|
||||||
|
|
||||||
|
BODY HTML:
|
||||||
|
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
From: fromuser@inbucket.org
|
||||||
|
To: recipient@inbucket.org
|
||||||
|
Subject: basic subject
|
||||||
|
|
||||||
|
Basic message.
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Mailbox: recipient
|
||||||
|
From: X-äéß Y-äéß <fromuser@inbucket.org>
|
||||||
|
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
|
||||||
|
Subject: Test of ȇɲʢȯȡɪɴʛ
|
||||||
|
Size: 351
|
||||||
|
|
||||||
|
BODY TEXT:
|
||||||
|
Basic message.
|
||||||
|
|
||||||
|
|
||||||
|
BODY HTML:
|
||||||
|
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?= <fromuser@inbucket.org>
|
||||||
|
To: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
|
||||||
|
Subject: =?utf-8?b?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||||
|
|
||||||
|
Basic message.
|
||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
Mailbox: recipient
|
||||||
|
From: From User <fromuser@inbucket.org>
|
||||||
|
To: [Rec I. Pient <recipient@inbucket.org>]
|
||||||
|
Subject: basic subject
|
||||||
|
Size: 246
|
||||||
|
|
||||||
|
BODY TEXT:
|
||||||
|
Basic message.
|
||||||
|
|
||||||
|
|
||||||
|
BODY HTML:
|
||||||
|
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
From: From User <fromuser@inbucket.org>
|
||||||
|
To: "Rec I. Pient" <recipient@inbucket.org>
|
||||||
|
Subject: basic subject
|
||||||
|
|
||||||
|
Basic message.
|
||||||
+58
-175
@@ -1,144 +1,82 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/inbucket/inbucket/pkg/storage"
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
"github.com/inbucket/inbucket/pkg/stringutil"
|
||||||
|
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MailboxIndex renders the index page for a particular mailbox
|
// MailboxMessage outputs a particular message as JSON for the UI.
|
||||||
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Form values must be validated manually
|
|
||||||
name := req.FormValue("name")
|
|
||||||
selected := req.FormValue("id")
|
|
||||||
if len(name) == 0 {
|
|
||||||
ctx.Session.AddFlash("Account name is required", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name, err = ctx.Manager.MailboxForAddress(name)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Remember this mailbox was visited
|
|
||||||
RememberMailbox(ctx, name)
|
|
||||||
// Get flash messages, save session
|
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"errorFlash": errorFlash,
|
|
||||||
"name": name,
|
|
||||||
"selected": selected,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
|
|
||||||
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Build redirect
|
|
||||||
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
|
|
||||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxLink handles pretty links to a particular message. Renders a redirect
|
|
||||||
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
id := ctx.Vars["id"]
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Build redirect
|
|
||||||
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
|
|
||||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxList renders a list of messages in a mailbox. Renders a partial
|
|
||||||
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
messages, err := ctx.Manager.GetMetadata(name)
|
|
||||||
if err != nil {
|
|
||||||
// This doesn't indicate empty, likely an IO error
|
|
||||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
|
||||||
}
|
|
||||||
// Render partial template
|
|
||||||
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"name": name,
|
|
||||||
"messages": messages,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
|
|
||||||
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
id := ctx.Vars["id"]
|
id := ctx.Vars["id"]
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
msg, err := ctx.Manager.GetMessage(name, id)
|
||||||
if err == storage.ErrNotExist {
|
if err != nil && err != storage.ErrNotExist {
|
||||||
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
// This doesn't indicate empty, likely an IO error
|
attachments := make([]*jsonAttachment, 0)
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
for i, part := range msg.Attachments() {
|
||||||
|
attachments = append(attachments, &jsonAttachment{
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
FileName: part.FileName,
|
||||||
|
ContentType: part.ContentType,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
body := template.HTML(web.TextToHTML(msg.Text()))
|
|
||||||
htmlAvailable := msg.HTML() != ""
|
mimeErrors := make([]*jsonMIMEError, 0)
|
||||||
var htmlBody template.HTML
|
for _, e := range msg.MIMEErrors() {
|
||||||
if htmlAvailable {
|
mimeErrors = append(mimeErrors, &jsonMIMEError{
|
||||||
|
Name: e.Name,
|
||||||
|
Detail: e.Detail,
|
||||||
|
Severe: e.Severe,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize HTML body.
|
||||||
|
htmlBody := ""
|
||||||
|
if msg.HTML() != "" {
|
||||||
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
||||||
htmlBody = template.HTML(str)
|
htmlBody = str
|
||||||
} else {
|
} else {
|
||||||
// Soft failure, render empty tab.
|
htmlBody = "Inbucket HTML sanitizer failed."
|
||||||
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
||||||
Msg("HTML sanitizer failed")
|
Msg("HTML sanitizer failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render partial template
|
|
||||||
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
|
return web.RenderJSON(w,
|
||||||
"ctx": ctx,
|
&jsonMessage{
|
||||||
"name": name,
|
Mailbox: name,
|
||||||
"message": msg,
|
ID: msg.ID,
|
||||||
"body": body,
|
From: stringutil.StringAddress(msg.From),
|
||||||
"htmlAvailable": htmlAvailable,
|
To: stringutil.StringAddressList(msg.To),
|
||||||
"htmlBody": htmlBody,
|
Subject: msg.Subject,
|
||||||
"mimeErrors": msg.MIMEErrors(),
|
Date: msg.Date,
|
||||||
"attachments": msg.Attachments(),
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
})
|
Size: msg.Size,
|
||||||
|
Seen: msg.Seen,
|
||||||
|
Header: msg.Header(),
|
||||||
|
Text: web.TextToHTML(msg.Text()),
|
||||||
|
HTML: htmlBody,
|
||||||
|
Attachments: attachments,
|
||||||
|
Errors: mimeErrors,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxHTML displays the HTML content of a message. Renders a partial
|
// MailboxHTML displays the HTML content of a message. Renders a partial
|
||||||
@@ -158,14 +96,10 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *web.Context) (er
|
|||||||
// This doesn't indicate empty, likely an IO error
|
// This doesn't indicate empty, likely an IO error
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
}
|
}
|
||||||
// Render partial template
|
// Render HTML
|
||||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||||
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
|
_, err = w.Write([]byte(msg.HTML()))
|
||||||
"ctx": ctx,
|
return err
|
||||||
"name": name,
|
|
||||||
"message": msg,
|
|
||||||
"body": template.HTML(msg.HTML()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
|
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
|
||||||
@@ -191,66 +125,18 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxDownloadAttach sends the attachment to the client; disposition:
|
|
||||||
// attachment, type: application/octet-stream
|
|
||||||
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
id := ctx.Vars["id"]
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
numStr := ctx.Vars["num"]
|
|
||||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
|
||||||
if err == storage.ErrNotExist {
|
|
||||||
http.NotFound(w, req)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// This doesn't indicate empty, likely an IO error
|
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
|
||||||
}
|
|
||||||
if int(num) >= len(msg.Attachments()) {
|
|
||||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Output attachment
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", "attachment")
|
|
||||||
_, err = w.Write(msg.Attachments()[num].Content)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxViewAttach sends the attachment to the client for online viewing
|
// MailboxViewAttach sends the attachment to the client for online viewing
|
||||||
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
return err
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
id := ctx.Vars["id"]
|
id := ctx.Vars["id"]
|
||||||
numStr := ctx.Vars["num"]
|
numStr := ctx.Vars["num"]
|
||||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
num, err := strconv.ParseUint(numStr, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
return err
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
msg, err := ctx.Manager.GetMessage(name, id)
|
||||||
if err == storage.ErrNotExist {
|
if err == storage.ErrNotExist {
|
||||||
@@ -262,10 +148,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Contex
|
|||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
}
|
}
|
||||||
if int(num) >= len(msg.Attachments()) {
|
if int(num) >= len(msg.Attachments()) {
|
||||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
return errors.New("requested attachment number does not exist")
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
// Output attachment
|
// Output attachment
|
||||||
part := msg.Attachments()[num]
|
part := msg.Attachments()[num]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// jsonMessage formats message data for the UI.
|
||||||
|
type jsonMessage struct {
|
||||||
|
Mailbox string `json:"mailbox"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To []string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
PosixMillis int64 `json:"posix-millis"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
Header map[string][]string `json:"header"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
Attachments []*jsonAttachment `json:"attachments"`
|
||||||
|
Errors []*jsonMIMEError `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonAttachment formats attachment data for the UI.
|
||||||
|
type jsonAttachment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FileName string `json:"filename"`
|
||||||
|
ContentType string `json:"content-type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonMIMEError struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Severe bool `json:"severe"`
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package webui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// maximum mailboxes to remember
|
|
||||||
maxRemembered = 8
|
|
||||||
// session value key; referenced in templates, do not change
|
|
||||||
mailboxKey = "recentMailboxes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RememberMailbox manages the list of recently accessed mailboxes stored in the session
|
|
||||||
func RememberMailbox(ctx *web.Context, mailbox string) {
|
|
||||||
recent := RecentMailboxes(ctx)
|
|
||||||
newRecent := make([]string, 1, maxRemembered)
|
|
||||||
newRecent[0] = mailbox
|
|
||||||
|
|
||||||
for _, recBox := range recent {
|
|
||||||
// Insert until newRecent is full, but don't repeat the new mailbox
|
|
||||||
if len(newRecent) < maxRemembered && mailbox != recBox {
|
|
||||||
newRecent = append(newRecent, recBox)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Session.Values[mailboxKey] = newRecent
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentMailboxes returns a slice of the most recently accessed mailboxes
|
|
||||||
func RecentMailboxes(ctx *web.Context) []string {
|
|
||||||
val := ctx.Session.Values[mailboxKey]
|
|
||||||
recent, _ := val.([]string)
|
|
||||||
return recent
|
|
||||||
}
|
|
||||||
@@ -2,98 +2,52 @@ package webui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/config"
|
"github.com/inbucket/inbucket/pkg/config"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RootIndex serves the Inbucket landing page
|
// RootGreeting serves the Inbucket greeting.
|
||||||
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
|
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to load greeting: %v", err)
|
return fmt.Errorf("Failed to load greeting: %v", err)
|
||||||
}
|
}
|
||||||
// Get flash messages, save session
|
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
_, err = w.Write(greeting)
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("root/index.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"errorFlash": errorFlash,
|
|
||||||
"greeting": template.HTML(string(greeting)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RootMonitor serves the Inbucket monitor page
|
// RootStatus renders portions of the server configuration as JSON.
|
||||||
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
if !ctx.RootConfig.Web.MonitorVisible {
|
|
||||||
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Get flash messages, save session
|
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"errorFlash": errorFlash,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox
|
|
||||||
func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
if !ctx.RootConfig.Web.MonitorVisible {
|
|
||||||
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Get flash messages, save session
|
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"errorFlash": errorFlash,
|
|
||||||
"name": name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RootStatus serves the Inbucket status page
|
|
||||||
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Get flash messages, save session
|
root := ctx.RootConfig
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
retPeriod := ""
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
if root.Storage.RetentionPeriod > 0 {
|
||||||
return err
|
retPeriod = root.Storage.RetentionPeriod.String()
|
||||||
}
|
}
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("root/status.html", w, map[string]interface{}{
|
return web.RenderJSON(w,
|
||||||
"ctx": ctx,
|
&jsonServerConfig{
|
||||||
"errorFlash": errorFlash,
|
Version: config.Version,
|
||||||
"version": config.Version,
|
BuildDate: config.BuildDate,
|
||||||
"buildDate": config.BuildDate,
|
POP3Listener: root.POP3.Addr,
|
||||||
"smtpListener": ctx.RootConfig.SMTP.Addr,
|
WebListener: root.Web.Addr,
|
||||||
"pop3Listener": ctx.RootConfig.POP3.Addr,
|
SMTPConfig: jsonSMTPConfig{
|
||||||
"webListener": ctx.RootConfig.Web.Addr,
|
Addr: root.SMTP.Addr,
|
||||||
"smtpConfig": ctx.RootConfig.SMTP,
|
DefaultAccept: root.SMTP.DefaultAccept,
|
||||||
"storageConfig": ctx.RootConfig.Storage,
|
AcceptDomains: root.SMTP.AcceptDomains,
|
||||||
})
|
RejectDomains: root.SMTP.RejectDomains,
|
||||||
|
DefaultStore: root.SMTP.DefaultStore,
|
||||||
|
StoreDomains: root.SMTP.StoreDomains,
|
||||||
|
DiscardDomains: root.SMTP.DiscardDomains,
|
||||||
|
},
|
||||||
|
StorageConfig: jsonStorageConfig{
|
||||||
|
MailboxMsgCap: root.Storage.MailboxMsgCap,
|
||||||
|
StoreType: root.Storage.Type,
|
||||||
|
RetentionPeriod: retPeriod,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-21
@@ -3,35 +3,21 @@ package webui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/inbucket/inbucket/pkg/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRoutes populates routes for the webui into the provided Router
|
// SetupRoutes populates routes for the webui into the provided Router.
|
||||||
func SetupRoutes(r *mux.Router) {
|
func SetupRoutes(r *mux.Router) {
|
||||||
r.Path("/").Handler(
|
r.Path("/greeting").Handler(
|
||||||
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
web.Handler(RootGreeting)).Name("RootGreeting").Methods("GET")
|
||||||
r.Path("/monitor").Handler(
|
|
||||||
web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
|
|
||||||
r.Path("/monitor/{name}").Handler(
|
|
||||||
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
|
|
||||||
r.Path("/status").Handler(
|
r.Path("/status").Handler(
|
||||||
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
||||||
r.Path("/link/{name}/{id}").Handler(
|
|
||||||
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
|
|
||||||
r.Path("/mailbox").Handler(
|
|
||||||
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
|
|
||||||
r.Path("/mailbox/{name}").Handler(
|
|
||||||
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
|
|
||||||
r.Path("/mailbox/{name}/{id}").Handler(
|
r.Path("/mailbox/{name}/{id}").Handler(
|
||||||
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
|
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
|
||||||
r.Path("/mailbox/{name}/{id}/html").Handler(
|
r.Path("/mailbox/{name}/{id}/html").Handler(
|
||||||
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
|
web.Handler(MailboxHTML)).Name("MailboxHTML").Methods("GET")
|
||||||
r.Path("/mailbox/{name}/{id}/source").Handler(
|
r.Path("/mailbox/{name}/{id}/source").Handler(
|
||||||
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
||||||
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
|
r.Path("/mailbox/{name}/{id}/attach/{num}/{file}").Handler(
|
||||||
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
|
|
||||||
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
|
|
||||||
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
||||||
r.Path("/{name}").Handler(
|
|
||||||
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package sanitize_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
"github.com/inbucket/inbucket/pkg/webui/sanitize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHTMLPlainStrings test plain text passthrough
|
// TestHTMLPlainStrings test plain text passthrough
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
type jsonServerConfig struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
BuildDate string `json:"build-date"`
|
||||||
|
POP3Listener string `json:"pop3-listener"`
|
||||||
|
WebListener string `json:"web-listener"`
|
||||||
|
SMTPConfig jsonSMTPConfig `json:"smtp-config"`
|
||||||
|
StorageConfig jsonStorageConfig `json:"storage-config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonSMTPConfig struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
DefaultAccept bool `json:"default-accept"`
|
||||||
|
AcceptDomains []string `json:"accept-domains"`
|
||||||
|
RejectDomains []string `json:"reject-domains"`
|
||||||
|
DefaultStore bool `json:"default-store"`
|
||||||
|
StoreDomains []string `json:"store-domains"`
|
||||||
|
DiscardDomains []string `json:"discard-domains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonStorageConfig struct {
|
||||||
|
MailboxMsgCap int `json:"mailbox-msg-cap"`
|
||||||
|
StoreType string `json:"store-type"`
|
||||||
|
RetentionPeriod string `json:"retention-period"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
with import <nixpkgs> {};
|
||||||
|
stdenv.mkDerivation rec {
|
||||||
|
name = "env";
|
||||||
|
env = buildEnv { name = name; paths = buildInputs; };
|
||||||
|
buildInputs = [
|
||||||
|
dpkg
|
||||||
|
elmPackages.elm
|
||||||
|
elmPackages.elm-analyse
|
||||||
|
elmPackages.elm-format
|
||||||
|
elmPackages.elm-language-server
|
||||||
|
elmPackages.elm-test
|
||||||
|
go
|
||||||
|
golint
|
||||||
|
nodejs-10_x
|
||||||
|
rpm
|
||||||
|
swaks
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Inbucket User Interface
|
||||||
|
|
||||||
|
This directory contains the source code for the Inbucket web user interface.
|
||||||
|
It is written in [Elm] 0.19, a *delightful language for reliable webapps.*
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
With `$INBUCKET` as the root of the git repository.
|
||||||
|
|
||||||
|
One time setup (assuming [Node.js] is already installed):
|
||||||
|
|
||||||
|
```
|
||||||
|
cd $INBUCKET/ui
|
||||||
|
npm i elm -g
|
||||||
|
npm i
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
|
||||||
|
|
||||||
|
### Terminal 1: inbucket daemon
|
||||||
|
|
||||||
|
```
|
||||||
|
cd $INBUCKET
|
||||||
|
make
|
||||||
|
etc/dev-start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Inbucket will start, with HTTP listening on port 9000. You may verify the web
|
||||||
|
UI is functional if this is your first time building Inbucket, but your dev/test
|
||||||
|
cycle should favor the development server below.
|
||||||
|
|
||||||
|
### Terminal 2: webpack development server
|
||||||
|
|
||||||
|
```
|
||||||
|
cd $INBUCKET/ui
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
npm will start a development HTTP server listening on port 3000. You should use
|
||||||
|
this server for UI development, as it features hot reload and the Elm debugger.
|
||||||
|
|
||||||
|
[Elm]: https://elm-lang.org
|
||||||
|
[Node.js]: https://nodejs.org
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"elm-version": "0.19.1",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
||||||
|
"basti1302/elm-human-readable-filesize": "1.2.0",
|
||||||
|
"elm/browser": "1.0.2",
|
||||||
|
"elm/core": "1.0.5",
|
||||||
|
"elm/html": "1.0.0",
|
||||||
|
"elm/http": "2.0.0",
|
||||||
|
"elm/json": "1.1.3",
|
||||||
|
"elm/svg": "1.0.1",
|
||||||
|
"elm/time": "1.0.0",
|
||||||
|
"elm/url": "1.0.0",
|
||||||
|
"jweir/sparkline": "4.0.0",
|
||||||
|
"ryannhg/date-format": "2.3.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/bytes": "1.0.8",
|
||||||
|
"elm/file": "1.0.5",
|
||||||
|
"elm/regex": "1.0.0",
|
||||||
|
"elm/virtual-dom": "1.0.2",
|
||||||
|
"myrho/elm-round": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test-dependencies": {
|
||||||
|
"direct": {},
|
||||||
|
"indirect": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -1,9 +1,11 @@
|
|||||||
|
<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 message can be customized by editing greeting.html. Change the
|
<p>This message can be customized by editing <code>ui/greeting.html</code>.
|
||||||
configuration option <code>greeting.file</code> if you'd like to move it
|
Set the <code>INBUCKET_WEB_GREETINGFILE</code> environment variable if you'd
|
||||||
outside of the Inbucket installation directory.</p>
|
like to move the file outside of the Inbucket installation directory.</p>
|
||||||
|
|||||||
Generated
+8039
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "inbucket-ui",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack --mode production",
|
||||||
|
"watch": "webpack --mode development --watch",
|
||||||
|
"dev": "webpack-dev-server --mode development --host 0.0.0.0 --port 3000 --hot",
|
||||||
|
"errors": "webpack --mode development --display-error-details"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.11.4",
|
||||||
|
"@babel/preset-env": "^7.11.0",
|
||||||
|
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||||
|
"@webcomponents/webcomponentsjs": "^2.4.4",
|
||||||
|
"babel-loader": "^8.1.0",
|
||||||
|
"css-loader": "^4.2.2",
|
||||||
|
"elm-hot-webpack-loader": "^1.1.7",
|
||||||
|
"elm-webpack-loader": "^7.0.1",
|
||||||
|
"file-loader": "^6.0.0",
|
||||||
|
"html-webpack-plugin": "^4.4.1",
|
||||||
|
"node-elm-compiler": "^5.0.5",
|
||||||
|
"style-loader": "^1.2.1",
|
||||||
|
"webpack": "^4.44.1",
|
||||||
|
"webpack-cli": "^3.3.12",
|
||||||
|
"webpack-dev-server": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- This index file will be served by the webpack development server. -->
|
||||||
|
<base href="/">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<title>Inbucket</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
You need to enable JavaScript to run this app.
|
||||||
|
</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<base href="{{ .BasePath }}">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<title>Inbucket</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
You need to enable JavaScript to run this app.
|
||||||
|
</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Inbucket",
|
||||||
|
"name": "Inbucket",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.png",
|
||||||
|
"sizes": "16x16",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "./index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
module Api exposing
|
||||||
|
( deleteMessage
|
||||||
|
, getGreeting
|
||||||
|
, getHeaderList
|
||||||
|
, getMessage
|
||||||
|
, getServerConfig
|
||||||
|
, getServerMetrics
|
||||||
|
, markMessageSeen
|
||||||
|
, monitorUri
|
||||||
|
, purgeMailbox
|
||||||
|
, serveUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
import Data.Message as Message exposing (Message)
|
||||||
|
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||||
|
import Data.Metrics as Metrics exposing (Metrics)
|
||||||
|
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
|
||||||
|
import Data.Session exposing (Session)
|
||||||
|
import Http
|
||||||
|
import HttpUtil
|
||||||
|
import Json.Decode as Decode
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import String
|
||||||
|
import Url.Builder
|
||||||
|
|
||||||
|
|
||||||
|
type alias DataResult msg data =
|
||||||
|
Result HttpUtil.Error data -> msg
|
||||||
|
|
||||||
|
|
||||||
|
type alias HttpResult msg =
|
||||||
|
Result HttpUtil.Error () -> msg
|
||||||
|
|
||||||
|
|
||||||
|
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||||
|
deleteMessage session msg mailboxName id =
|
||||||
|
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||||
|
|
||||||
|
|
||||||
|
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
|
||||||
|
getHeaderList session msg mailboxName =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "GET"
|
||||||
|
, url = apiV1Url session [ "mailbox", mailboxName ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.get
|
||||||
|
{ url = context.url
|
||||||
|
, expect = HttpUtil.expectJson context msg (Decode.list MessageHeader.decoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getGreeting : Session -> DataResult msg String -> Cmd msg
|
||||||
|
getGreeting session msg =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "GET"
|
||||||
|
, url = serveUrl session [ "greeting" ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.get
|
||||||
|
{ url = context.url
|
||||||
|
, expect = HttpUtil.expectString context msg
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
|
||||||
|
getMessage session msg mailboxName id =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "GET"
|
||||||
|
, url = serveUrl session [ "mailbox", mailboxName, id ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.get
|
||||||
|
{ url = context.url
|
||||||
|
, expect = HttpUtil.expectJson context msg Message.decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
|
||||||
|
getServerConfig session msg =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "GET"
|
||||||
|
, url = serveUrl session [ "status" ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.get
|
||||||
|
{ url = context.url
|
||||||
|
, expect = HttpUtil.expectJson context msg ServerConfig.decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
|
||||||
|
getServerMetrics session msg =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "GET"
|
||||||
|
, url =
|
||||||
|
Url.Builder.absolute
|
||||||
|
(splitBasePath session.config.basePath
|
||||||
|
++ [ "debug"
|
||||||
|
, "vars"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.get
|
||||||
|
{ url = context.url
|
||||||
|
, expect = HttpUtil.expectJson context msg Metrics.decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
|
||||||
|
markMessageSeen session msg mailboxName id =
|
||||||
|
-- The URL tells the API which message ID to update, so we only need to indicate the
|
||||||
|
-- desired change in the body.
|
||||||
|
Encode.object [ ( "seen", Encode.bool True ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
|
||||||
|
|
||||||
|
|
||||||
|
monitorUri : Session -> String
|
||||||
|
monitorUri session =
|
||||||
|
apiV1Url session [ "monitor", "messages" ]
|
||||||
|
|
||||||
|
|
||||||
|
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
|
||||||
|
purgeMailbox session msg mailboxName =
|
||||||
|
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
|
||||||
|
|
||||||
|
|
||||||
|
{-| Builds a public REST API URL (see wiki).
|
||||||
|
-}
|
||||||
|
apiV1Url : Session -> List String -> String
|
||||||
|
apiV1Url session elements =
|
||||||
|
Url.Builder.absolute
|
||||||
|
(List.concat
|
||||||
|
[ splitBasePath session.config.basePath
|
||||||
|
, [ "api", "v1" ]
|
||||||
|
, elements
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Builds an internal `serve` REST API URL; only used by this UI.
|
||||||
|
-}
|
||||||
|
serveUrl : Session -> List String -> String
|
||||||
|
serveUrl session elements =
|
||||||
|
Url.Builder.absolute
|
||||||
|
(List.concat
|
||||||
|
[ splitBasePath session.config.basePath
|
||||||
|
, [ "serve" ]
|
||||||
|
, elements
|
||||||
|
]
|
||||||
|
)
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Converts base path into a list of path elements.
|
||||||
|
-}
|
||||||
|
splitBasePath : String -> List String
|
||||||
|
splitBasePath path =
|
||||||
|
if path == "" then
|
||||||
|
[]
|
||||||
|
|
||||||
|
else
|
||||||
|
let
|
||||||
|
stripSlashes str =
|
||||||
|
if String.startsWith "/" str then
|
||||||
|
stripSlashes (String.dropLeft 1 str)
|
||||||
|
|
||||||
|
else if String.endsWith "/" str then
|
||||||
|
stripSlashes (String.dropRight 1 str)
|
||||||
|
|
||||||
|
else
|
||||||
|
str
|
||||||
|
|
||||||
|
newPath =
|
||||||
|
stripSlashes path
|
||||||
|
in
|
||||||
|
String.split "/" newPath
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module Data.AppConfig exposing (AppConfig, decoder, default)
|
||||||
|
|
||||||
|
import Json.Decode as D
|
||||||
|
import Json.Decode.Pipeline as P
|
||||||
|
|
||||||
|
|
||||||
|
type alias AppConfig =
|
||||||
|
{ basePath : String
|
||||||
|
, monitorVisible : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
decoder : D.Decoder AppConfig
|
||||||
|
decoder =
|
||||||
|
D.succeed AppConfig
|
||||||
|
|> P.optional "base-path" D.string ""
|
||||||
|
|> P.required "monitor-visible" D.bool
|
||||||
|
|
||||||
|
|
||||||
|
default : AppConfig
|
||||||
|
default =
|
||||||
|
AppConfig "" True
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module Data.Date exposing (date)
|
||||||
|
|
||||||
|
import Json.Decode exposing (Decoder, int, map)
|
||||||
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Decode a POSIX milliseconds timestamp.
|
||||||
|
-}
|
||||||
|
date : Decoder Posix
|
||||||
|
date =
|
||||||
|
int |> map Time.millisToPosix
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
|
||||||
|
|
||||||
|
import Data.Date exposing (date)
|
||||||
|
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||||
|
import Json.Decode.Pipeline exposing (optional, required)
|
||||||
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
type alias Message =
|
||||||
|
{ mailbox : String
|
||||||
|
, id : String
|
||||||
|
, from : String
|
||||||
|
, to : List String
|
||||||
|
, subject : String
|
||||||
|
, date : Posix
|
||||||
|
, size : Int
|
||||||
|
, seen : Bool
|
||||||
|
, text : String
|
||||||
|
, html : String
|
||||||
|
, attachments : List Attachment
|
||||||
|
, errors : List Error
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Attachment =
|
||||||
|
{ id : String
|
||||||
|
, fileName : String
|
||||||
|
, contentType : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Error =
|
||||||
|
{ name : String
|
||||||
|
, detail : String
|
||||||
|
, severe : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Message
|
||||||
|
decoder =
|
||||||
|
succeed Message
|
||||||
|
|> required "mailbox" string
|
||||||
|
|> required "id" string
|
||||||
|
|> optional "from" string ""
|
||||||
|
|> required "to" (list string)
|
||||||
|
|> optional "subject" string ""
|
||||||
|
|> required "posix-millis" date
|
||||||
|
|> required "size" int
|
||||||
|
|> required "seen" bool
|
||||||
|
|> required "text" string
|
||||||
|
|> required "html" string
|
||||||
|
|> required "attachments" (list attachmentDecoder)
|
||||||
|
|> required "errors" (list errorDecoder)
|
||||||
|
|
||||||
|
|
||||||
|
attachmentDecoder : Decoder Attachment
|
||||||
|
attachmentDecoder =
|
||||||
|
succeed Attachment
|
||||||
|
|> required "id" string
|
||||||
|
|> required "filename" string
|
||||||
|
|> required "content-type" string
|
||||||
|
|
||||||
|
|
||||||
|
errorDecoder : Decoder Error
|
||||||
|
errorDecoder =
|
||||||
|
succeed Error
|
||||||
|
|> required "name" string
|
||||||
|
|> required "detail" string
|
||||||
|
|> required "severe" bool
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
module Data.MessageHeader exposing (MessageHeader, decoder)
|
||||||
|
|
||||||
|
import Data.Date exposing (date)
|
||||||
|
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
|
||||||
|
import Json.Decode.Pipeline exposing (optional, required)
|
||||||
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
type alias MessageHeader =
|
||||||
|
{ mailbox : String
|
||||||
|
, id : String
|
||||||
|
, from : String
|
||||||
|
, to : List String
|
||||||
|
, subject : String
|
||||||
|
, date : Posix
|
||||||
|
, size : Int
|
||||||
|
, seen : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder MessageHeader
|
||||||
|
decoder =
|
||||||
|
succeed MessageHeader
|
||||||
|
|> required "mailbox" string
|
||||||
|
|> required "id" string
|
||||||
|
|> optional "from" string ""
|
||||||
|
|> required "to" (list string)
|
||||||
|
|> optional "subject" string ""
|
||||||
|
|> required "posix-millis" date
|
||||||
|
|> required "size" int
|
||||||
|
|> required "seen" bool
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
|
||||||
|
|
||||||
|
import Data.Date exposing (date)
|
||||||
|
import Json.Decode exposing (Decoder, int, map, string, succeed)
|
||||||
|
import Json.Decode.Pipeline exposing (requiredAt)
|
||||||
|
import Time exposing (Posix)
|
||||||
|
|
||||||
|
|
||||||
|
type alias Metrics =
|
||||||
|
{ startTime : Posix
|
||||||
|
, sysMem : Int
|
||||||
|
, heapSize : Int
|
||||||
|
, heapUsed : Int
|
||||||
|
, heapObjects : Int
|
||||||
|
, goRoutines : Int
|
||||||
|
, webSockets : Int
|
||||||
|
, smtpConnOpen : Int
|
||||||
|
, smtpConnTotal : Int
|
||||||
|
, smtpConnHist : List Int
|
||||||
|
, smtpReceivedTotal : Int
|
||||||
|
, smtpReceivedHist : List Int
|
||||||
|
, smtpErrorsTotal : Int
|
||||||
|
, smtpErrorsHist : List Int
|
||||||
|
, smtpWarnsTotal : Int
|
||||||
|
, smtpWarnsHist : List Int
|
||||||
|
, retentionDeletesTotal : Int
|
||||||
|
, retentionDeletesHist : List Int
|
||||||
|
, retainedCount : Int
|
||||||
|
, retainedCountHist : List Int
|
||||||
|
, retainedSize : Int
|
||||||
|
, retainedSizeHist : List Int
|
||||||
|
, scanCompleted : Posix
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Metrics
|
||||||
|
decoder =
|
||||||
|
succeed Metrics
|
||||||
|
|> requiredAt [ "startMillis" ] date
|
||||||
|
|> requiredAt [ "memstats", "Sys" ] int
|
||||||
|
|> requiredAt [ "memstats", "HeapSys" ] int
|
||||||
|
|> requiredAt [ "memstats", "HeapAlloc" ] int
|
||||||
|
|> requiredAt [ "memstats", "HeapObjects" ] int
|
||||||
|
|> requiredAt [ "goroutines" ] int
|
||||||
|
|> requiredAt [ "http", "WebSocketConnectsCurrent" ] int
|
||||||
|
|> requiredAt [ "smtp", "ConnectsCurrent" ] int
|
||||||
|
|> requiredAt [ "smtp", "ConnectsTotal" ] int
|
||||||
|
|> requiredAt [ "smtp", "ConnectsHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "smtp", "ReceivedTotal" ] int
|
||||||
|
|> requiredAt [ "smtp", "ReceivedHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "smtp", "ErrorsTotal" ] int
|
||||||
|
|> requiredAt [ "smtp", "ErrorsHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "smtp", "WarnsTotal" ] int
|
||||||
|
|> requiredAt [ "smtp", "WarnsHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "retention", "DeletesTotal" ] int
|
||||||
|
|> requiredAt [ "retention", "DeletesHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "retention", "RetainedCurrent" ] int
|
||||||
|
|> requiredAt [ "retention", "RetainedHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "retention", "RetainedSize" ] int
|
||||||
|
|> requiredAt [ "retention", "SizeHist" ] decodeIntList
|
||||||
|
|> requiredAt [ "retention", "ScanCompletedMillis" ] date
|
||||||
|
|
||||||
|
|
||||||
|
{-| Decodes Inbuckets hacky comma-separated-int JSON strings.
|
||||||
|
-}
|
||||||
|
decodeIntList : Decoder (List Int)
|
||||||
|
decodeIntList =
|
||||||
|
string
|
||||||
|
|> map (String.split ",")
|
||||||
|
|> map (List.map (String.toInt >> Maybe.withDefault 0))
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
module Data.ServerConfig exposing (ServerConfig, decoder, encode)
|
||||||
|
|
||||||
|
import Json.Decode as D
|
||||||
|
import Json.Decode.Pipeline as P
|
||||||
|
import Json.Encode as E
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- Generated by https://github.com/jhillyerd/go-to-elm-json
|
||||||
|
|
||||||
|
|
||||||
|
type alias ServerConfig =
|
||||||
|
{ version : String
|
||||||
|
, buildDate : String
|
||||||
|
, pop3Listener : String
|
||||||
|
, webListener : String
|
||||||
|
, smtpConfig : SmtpConfig
|
||||||
|
, storageConfig : StorageConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias SmtpConfig =
|
||||||
|
{ addr : String
|
||||||
|
, defaultAccept : Bool
|
||||||
|
, acceptDomains : Maybe (List String)
|
||||||
|
, rejectDomains : Maybe (List String)
|
||||||
|
, defaultStore : Bool
|
||||||
|
, storeDomains : Maybe (List String)
|
||||||
|
, discardDomains : Maybe (List String)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias StorageConfig =
|
||||||
|
{ mailboxMsgCap : Int
|
||||||
|
, storeType : String
|
||||||
|
, retentionPeriod : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
decoder : D.Decoder ServerConfig
|
||||||
|
decoder =
|
||||||
|
D.succeed ServerConfig
|
||||||
|
|> P.required "version" D.string
|
||||||
|
|> P.required "build-date" D.string
|
||||||
|
|> P.required "pop3-listener" D.string
|
||||||
|
|> P.required "web-listener" D.string
|
||||||
|
|> P.required "smtp-config" smtpConfigDecoder
|
||||||
|
|> P.required "storage-config" storageConfigDecoder
|
||||||
|
|
||||||
|
|
||||||
|
encode : ServerConfig -> E.Value
|
||||||
|
encode r =
|
||||||
|
E.object
|
||||||
|
[ ( "version", E.string r.version )
|
||||||
|
, ( "build-date", E.string r.buildDate )
|
||||||
|
, ( "pop3-listener", E.string r.pop3Listener )
|
||||||
|
, ( "web-listener", E.string r.webListener )
|
||||||
|
, ( "smtp-config", encodeSmtpConfig r.smtpConfig )
|
||||||
|
, ( "storage-config", encodeStorageConfig r.storageConfig )
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
smtpConfigDecoder : D.Decoder SmtpConfig
|
||||||
|
smtpConfigDecoder =
|
||||||
|
D.succeed SmtpConfig
|
||||||
|
|> P.required "addr" D.string
|
||||||
|
|> P.required "default-accept" D.bool
|
||||||
|
|> P.required "accept-domains" (D.nullable (D.list D.string))
|
||||||
|
|> P.required "reject-domains" (D.nullable (D.list D.string))
|
||||||
|
|> P.required "default-store" D.bool
|
||||||
|
|> P.required "store-domains" (D.nullable (D.list D.string))
|
||||||
|
|> P.required "discard-domains" (D.nullable (D.list D.string))
|
||||||
|
|
||||||
|
|
||||||
|
encodeSmtpConfig : SmtpConfig -> E.Value
|
||||||
|
encodeSmtpConfig r =
|
||||||
|
E.object
|
||||||
|
[ ( "addr", E.string r.addr )
|
||||||
|
, ( "default-accept", E.bool r.defaultAccept )
|
||||||
|
, ( "accept-domains", maybe (E.list E.string) r.acceptDomains )
|
||||||
|
, ( "reject-domains", maybe (E.list E.string) r.rejectDomains )
|
||||||
|
, ( "default-store", E.bool r.defaultStore )
|
||||||
|
, ( "store-domains", maybe (E.list E.string) r.storeDomains )
|
||||||
|
, ( "discard-domains", maybe (E.list E.string) r.discardDomains )
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
storageConfigDecoder : D.Decoder StorageConfig
|
||||||
|
storageConfigDecoder =
|
||||||
|
D.succeed StorageConfig
|
||||||
|
|> P.required "mailbox-msg-cap" D.int
|
||||||
|
|> P.required "store-type" D.string
|
||||||
|
|> P.required "retention-period" D.string
|
||||||
|
|
||||||
|
|
||||||
|
encodeStorageConfig : StorageConfig -> E.Value
|
||||||
|
encodeStorageConfig r =
|
||||||
|
E.object
|
||||||
|
[ ( "mailbox-msg-cap", E.int r.mailboxMsgCap )
|
||||||
|
, ( "store-type", E.string r.storeType )
|
||||||
|
, ( "retention-period", E.string r.retentionPeriod )
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
maybe : (a -> E.Value) -> Maybe a -> E.Value
|
||||||
|
maybe encoder =
|
||||||
|
Maybe.map encoder >> Maybe.withDefault E.null
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
module Data.Session exposing
|
||||||
|
( Flash
|
||||||
|
, Persistent
|
||||||
|
, Session
|
||||||
|
, addRecent
|
||||||
|
, clearFlash
|
||||||
|
, decoder
|
||||||
|
, disableRouting
|
||||||
|
, enableRouting
|
||||||
|
, encode
|
||||||
|
, init
|
||||||
|
, initError
|
||||||
|
, showFlash
|
||||||
|
)
|
||||||
|
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||||
|
import Json.Decode as D
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as E
|
||||||
|
import Route exposing (Router)
|
||||||
|
import Time
|
||||||
|
import Url exposing (Url)
|
||||||
|
|
||||||
|
|
||||||
|
type alias Session =
|
||||||
|
{ key : Nav.Key
|
||||||
|
, host : String
|
||||||
|
, flash : Maybe Flash
|
||||||
|
, routing : Bool
|
||||||
|
, router : Router
|
||||||
|
, zone : Time.Zone
|
||||||
|
, config : AppConfig
|
||||||
|
, persistent : Persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Flash =
|
||||||
|
{ title : String
|
||||||
|
, table : List ( String, String )
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Persistent =
|
||||||
|
{ recentMailboxes : List String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Nav.Key -> Url -> AppConfig -> Persistent -> Session
|
||||||
|
init key location config persistent =
|
||||||
|
{ key = key
|
||||||
|
, host = location.host
|
||||||
|
, flash = Nothing
|
||||||
|
, routing = True
|
||||||
|
, router = Route.newRouter config.basePath
|
||||||
|
, zone = Time.utc
|
||||||
|
, config = config
|
||||||
|
, persistent = persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
initError : Nav.Key -> Url -> String -> Session
|
||||||
|
initError key location error =
|
||||||
|
{ key = key
|
||||||
|
, host = location.host
|
||||||
|
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
|
||||||
|
, routing = True
|
||||||
|
, router = Route.newRouter ""
|
||||||
|
, zone = Time.utc
|
||||||
|
, config = AppConfig.default
|
||||||
|
, persistent = Persistent []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
addRecent : String -> Session -> Session
|
||||||
|
addRecent mailbox session =
|
||||||
|
if List.head session.persistent.recentMailboxes == Just mailbox then
|
||||||
|
session
|
||||||
|
|
||||||
|
else
|
||||||
|
let
|
||||||
|
recent =
|
||||||
|
session.persistent.recentMailboxes
|
||||||
|
|> List.filter ((/=) mailbox)
|
||||||
|
|> List.take 7
|
||||||
|
|> (::) mailbox
|
||||||
|
|
||||||
|
persistent =
|
||||||
|
session.persistent
|
||||||
|
in
|
||||||
|
{ session | persistent = { persistent | recentMailboxes = recent } }
|
||||||
|
|
||||||
|
|
||||||
|
disableRouting : Session -> Session
|
||||||
|
disableRouting session =
|
||||||
|
{ session | routing = False }
|
||||||
|
|
||||||
|
|
||||||
|
enableRouting : Session -> Session
|
||||||
|
enableRouting session =
|
||||||
|
{ session | routing = True }
|
||||||
|
|
||||||
|
|
||||||
|
clearFlash : Session -> Session
|
||||||
|
clearFlash session =
|
||||||
|
{ session | flash = Nothing }
|
||||||
|
|
||||||
|
|
||||||
|
showFlash : Flash -> Session -> Session
|
||||||
|
showFlash flash session =
|
||||||
|
{ session | flash = Just flash }
|
||||||
|
|
||||||
|
|
||||||
|
decoder : D.Decoder Persistent
|
||||||
|
decoder =
|
||||||
|
D.succeed Persistent
|
||||||
|
|> optional "recentMailboxes" (D.list D.string) []
|
||||||
|
|
||||||
|
|
||||||
|
encode : Persistent -> E.Value
|
||||||
|
encode persistent =
|
||||||
|
E.object
|
||||||
|
[ ( "recentMailboxes", E.list E.string persistent.recentMailboxes )
|
||||||
|
]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
|
||||||
|
|
||||||
|
import Data.Session as Session
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode
|
||||||
|
|
||||||
|
|
||||||
|
type alias Error =
|
||||||
|
{ error : Http.Error
|
||||||
|
, request : RequestContext
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias RequestContext =
|
||||||
|
{ method : String
|
||||||
|
, url : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
delete : (Result Error () -> msg) -> String -> Cmd msg
|
||||||
|
delete msg url =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "DELETE"
|
||||||
|
, url = url
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.request
|
||||||
|
{ method = context.method
|
||||||
|
, headers = []
|
||||||
|
, url = url
|
||||||
|
, body = Http.emptyBody
|
||||||
|
, expect = expectWhatever context msg
|
||||||
|
, timeout = Nothing
|
||||||
|
, tracker = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
patch : (Result Error () -> msg) -> String -> Http.Body -> Cmd msg
|
||||||
|
patch msg url body =
|
||||||
|
let
|
||||||
|
context =
|
||||||
|
{ method = "PATCH"
|
||||||
|
, url = url
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Http.request
|
||||||
|
{ method = context.method
|
||||||
|
, headers = []
|
||||||
|
, url = url
|
||||||
|
, body = body
|
||||||
|
, expect = expectWhatever context msg
|
||||||
|
, timeout = Nothing
|
||||||
|
, tracker = Nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
errorFlash : Error -> Session.Flash
|
||||||
|
errorFlash error =
|
||||||
|
let
|
||||||
|
requestContext flash =
|
||||||
|
{ flash
|
||||||
|
| table =
|
||||||
|
flash.table
|
||||||
|
++ [ ( "Request URL", error.request.url )
|
||||||
|
, ( "HTTP Method", error.request.method )
|
||||||
|
]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
requestContext <|
|
||||||
|
case error.error of
|
||||||
|
Http.BadUrl str ->
|
||||||
|
{ title = "Bad URL"
|
||||||
|
, table = [ ( "URL", str ) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
Http.Timeout ->
|
||||||
|
{ title = "HTTP timeout"
|
||||||
|
, table = []
|
||||||
|
}
|
||||||
|
|
||||||
|
Http.NetworkError ->
|
||||||
|
{ title = "HTTP network error"
|
||||||
|
, table = []
|
||||||
|
}
|
||||||
|
|
||||||
|
Http.BadStatus res ->
|
||||||
|
{ title = "HTTP response error"
|
||||||
|
, table = [ ( "Response Code", String.fromInt res ) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
Http.BadBody body ->
|
||||||
|
{ title = "Bad HTTP body"
|
||||||
|
, table = [ ( "Error", body ) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
expectJson : RequestContext -> (Result Error a -> msg) -> Decode.Decoder a -> Http.Expect msg
|
||||||
|
expectJson context toMsg decoder =
|
||||||
|
Http.expectStringResponse toMsg <|
|
||||||
|
resolve context <|
|
||||||
|
\string ->
|
||||||
|
Result.mapError Decode.errorToString (Decode.decodeString decoder string)
|
||||||
|
|
||||||
|
|
||||||
|
expectString : RequestContext -> (Result Error String -> msg) -> Http.Expect msg
|
||||||
|
expectString context toMsg =
|
||||||
|
Http.expectStringResponse toMsg (resolve context Ok)
|
||||||
|
|
||||||
|
|
||||||
|
expectWhatever : RequestContext -> (Result Error () -> msg) -> Http.Expect msg
|
||||||
|
expectWhatever context toMsg =
|
||||||
|
Http.expectBytesResponse toMsg (resolve context (\_ -> Ok ()))
|
||||||
|
|
||||||
|
|
||||||
|
resolve : RequestContext -> (body -> Result String a) -> Http.Response body -> Result Error a
|
||||||
|
resolve context toResult response =
|
||||||
|
case response of
|
||||||
|
Http.BadUrl_ url ->
|
||||||
|
Err (Error (Http.BadUrl url) context)
|
||||||
|
|
||||||
|
Http.Timeout_ ->
|
||||||
|
Err (Error Http.Timeout context)
|
||||||
|
|
||||||
|
Http.NetworkError_ ->
|
||||||
|
Err (Error Http.NetworkError context)
|
||||||
|
|
||||||
|
Http.BadStatus_ metadata _ ->
|
||||||
|
Err (Error (Http.BadStatus metadata.statusCode) context)
|
||||||
|
|
||||||
|
Http.GoodStatus_ _ body ->
|
||||||
|
Result.mapError (\x -> Error (Http.BadBody x) context) (toResult body)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
|
||||||
|
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Data.Session as Session exposing (Session)
|
||||||
|
import Html
|
||||||
|
exposing
|
||||||
|
( Attribute
|
||||||
|
, Html
|
||||||
|
, a
|
||||||
|
, button
|
||||||
|
, div
|
||||||
|
, footer
|
||||||
|
, form
|
||||||
|
, h2
|
||||||
|
, header
|
||||||
|
, i
|
||||||
|
, input
|
||||||
|
, li
|
||||||
|
, nav
|
||||||
|
, pre
|
||||||
|
, span
|
||||||
|
, td
|
||||||
|
, text
|
||||||
|
, th
|
||||||
|
, tr
|
||||||
|
, ul
|
||||||
|
)
|
||||||
|
import Html.Attributes
|
||||||
|
exposing
|
||||||
|
( attribute
|
||||||
|
, class
|
||||||
|
, classList
|
||||||
|
, href
|
||||||
|
, placeholder
|
||||||
|
, rel
|
||||||
|
, target
|
||||||
|
, type_
|
||||||
|
, value
|
||||||
|
)
|
||||||
|
import Html.Events as Events
|
||||||
|
import Modal
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Timer exposing (Timer)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Used to highlight current page in navbar.
|
||||||
|
-}
|
||||||
|
type Page
|
||||||
|
= Other
|
||||||
|
| Mailbox
|
||||||
|
| Monitor
|
||||||
|
| Status
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model msg =
|
||||||
|
{ mapMsg : Msg -> msg
|
||||||
|
, mainMenuVisible : Bool
|
||||||
|
, recentMenuVisible : Bool
|
||||||
|
, recentMenuTimer : Timer
|
||||||
|
, mailboxName : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : (Msg -> msg) -> Model msg
|
||||||
|
init mapMsg =
|
||||||
|
{ mapMsg = mapMsg
|
||||||
|
, mainMenuVisible = False
|
||||||
|
, recentMenuVisible = False
|
||||||
|
, recentMenuTimer = Timer.empty
|
||||||
|
, mailboxName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{-| Resets layout state, used when navigating to a new page.
|
||||||
|
-}
|
||||||
|
reset : Model msg -> Model msg
|
||||||
|
reset model =
|
||||||
|
{ model
|
||||||
|
| mainMenuVisible = False
|
||||||
|
, recentMenuVisible = False
|
||||||
|
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||||
|
, mailboxName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClearFlash
|
||||||
|
| MainMenuToggled
|
||||||
|
| ModalFocused Modal.Msg
|
||||||
|
| ModalUnfocused
|
||||||
|
| OnMailboxNameInput String
|
||||||
|
| OpenMailbox
|
||||||
|
| RecentMenuMouseOver
|
||||||
|
| RecentMenuMouseOut
|
||||||
|
| RecentMenuTimeout Timer
|
||||||
|
| RecentMenuToggled
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
|
||||||
|
update msg model session =
|
||||||
|
case msg of
|
||||||
|
ClearFlash ->
|
||||||
|
( model
|
||||||
|
, Session.clearFlash session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
MainMenuToggled ->
|
||||||
|
( { model | mainMenuVisible = not model.mainMenuVisible }
|
||||||
|
, session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
ModalFocused message ->
|
||||||
|
( model
|
||||||
|
, Modal.updateSession message session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
ModalUnfocused ->
|
||||||
|
( model, session, Modal.resetFocusCmd (ModalFocused >> model.mapMsg) )
|
||||||
|
|
||||||
|
OnMailboxNameInput name ->
|
||||||
|
( { model | mailboxName = name }
|
||||||
|
, session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
OpenMailbox ->
|
||||||
|
if model.mailboxName == "" then
|
||||||
|
( model, session, Cmd.none )
|
||||||
|
|
||||||
|
else
|
||||||
|
( model
|
||||||
|
, session
|
||||||
|
, Route.Mailbox model.mailboxName
|
||||||
|
|> session.router.toPath
|
||||||
|
|> Nav.pushUrl session.key
|
||||||
|
)
|
||||||
|
|
||||||
|
RecentMenuMouseOver ->
|
||||||
|
( { model
|
||||||
|
| recentMenuVisible = True
|
||||||
|
, recentMenuTimer = Timer.cancel model.recentMenuTimer
|
||||||
|
}
|
||||||
|
, session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
RecentMenuMouseOut ->
|
||||||
|
let
|
||||||
|
newTimer =
|
||||||
|
Timer.replace model.recentMenuTimer
|
||||||
|
in
|
||||||
|
( { model
|
||||||
|
| recentMenuTimer = newTimer
|
||||||
|
}
|
||||||
|
, session
|
||||||
|
, Timer.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
|
||||||
|
)
|
||||||
|
|
||||||
|
RecentMenuTimeout timer ->
|
||||||
|
if timer == model.recentMenuTimer then
|
||||||
|
( { model
|
||||||
|
| recentMenuVisible = False
|
||||||
|
, recentMenuTimer = Timer.cancel timer
|
||||||
|
}
|
||||||
|
, session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
else
|
||||||
|
-- Timer was no longer valid.
|
||||||
|
( model, session, Cmd.none )
|
||||||
|
|
||||||
|
RecentMenuToggled ->
|
||||||
|
( { model | recentMenuVisible = not model.recentMenuVisible }
|
||||||
|
, session
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type alias State msg =
|
||||||
|
{ model : Model msg
|
||||||
|
, session : Session
|
||||||
|
, activePage : Page
|
||||||
|
, activeMailbox : String
|
||||||
|
, modal : Maybe (Html msg)
|
||||||
|
, content : List (Html msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
frame : State msg -> Html msg
|
||||||
|
frame { model, session, activePage, activeMailbox, modal, content } =
|
||||||
|
div [ class "app" ]
|
||||||
|
[ header []
|
||||||
|
[ nav [ class "navbar" ]
|
||||||
|
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
|
||||||
|
[ i [ class "fas fa-bars" ] [] ]
|
||||||
|
, span [ class "navbar-brand" ]
|
||||||
|
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
|
||||||
|
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
|
||||||
|
[ if session.config.monitorVisible then
|
||||||
|
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
|
||||||
|
|
||||||
|
else
|
||||||
|
text ""
|
||||||
|
, navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
|
||||||
|
, navbarRecent activePage activeMailbox model session
|
||||||
|
, li [ class "navbar-mailbox" ]
|
||||||
|
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
|
||||||
|
[ input
|
||||||
|
[ type_ "text"
|
||||||
|
, placeholder "mailbox"
|
||||||
|
, value model.mailboxName
|
||||||
|
, Events.onInput (OnMailboxNameInput >> model.mapMsg)
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
, div [ class "navbar-bg" ] [ text "" ]
|
||||||
|
, Modal.view (ModalUnfocused |> model.mapMsg) modal
|
||||||
|
, div [ class "page" ] (errorFlash model session.flash :: content)
|
||||||
|
, footer []
|
||||||
|
[ div [ class "footer" ]
|
||||||
|
[ externalLink "https://www.inbucket.org" "Inbucket"
|
||||||
|
, text " is an open source project hosted on "
|
||||||
|
, externalLink "https://github.com/inbucket/inbucket" "GitHub"
|
||||||
|
, text "."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
|
||||||
|
errorFlash model maybeFlash =
|
||||||
|
let
|
||||||
|
row ( heading, message ) =
|
||||||
|
tr []
|
||||||
|
[ th [] [ text (heading ++ ":") ]
|
||||||
|
, td [] [ pre [] [ text message ] ]
|
||||||
|
]
|
||||||
|
in
|
||||||
|
case maybeFlash of
|
||||||
|
Nothing ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
Just flash ->
|
||||||
|
div [ class "well well-error" ]
|
||||||
|
[ div [ class "flash-header" ]
|
||||||
|
[ h2 [] [ text flash.title ]
|
||||||
|
, a [ href "#", Events.onClick (ClearFlash |> model.mapMsg) ] [ text "Close" ]
|
||||||
|
]
|
||||||
|
, div [ class "flash-table" ] (List.map row flash.table)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
externalLink : String -> String -> Html a
|
||||||
|
externalLink url title =
|
||||||
|
a [ href url, target "_blank", rel "noopener" ] [ text title ]
|
||||||
|
|
||||||
|
|
||||||
|
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
|
||||||
|
navbarLink page url linkContent activePage =
|
||||||
|
li [ classList [ ( "navbar-active", page == activePage ) ] ]
|
||||||
|
[ a [ href url ] linkContent ]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
|
||||||
|
-}
|
||||||
|
navbarRecent : Page -> String -> Model msg -> Session -> Html msg
|
||||||
|
navbarRecent page activeMailbox model session =
|
||||||
|
let
|
||||||
|
-- Active means we are viewing a specific mailbox.
|
||||||
|
active =
|
||||||
|
page == Mailbox
|
||||||
|
|
||||||
|
-- Recent tab title is the name of the current mailbox when active.
|
||||||
|
title =
|
||||||
|
if active then
|
||||||
|
activeMailbox
|
||||||
|
|
||||||
|
else
|
||||||
|
"Recent Mailboxes"
|
||||||
|
|
||||||
|
-- Mailboxes to show in recent list, doesn't include active mailbox.
|
||||||
|
recentMailboxes =
|
||||||
|
if active then
|
||||||
|
List.tail session.persistent.recentMailboxes |> Maybe.withDefault []
|
||||||
|
|
||||||
|
else
|
||||||
|
session.persistent.recentMailboxes
|
||||||
|
|
||||||
|
recentLink mailbox =
|
||||||
|
a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
|
||||||
|
in
|
||||||
|
li
|
||||||
|
[ class "navbar-dropdown-container"
|
||||||
|
, classList [ ( "navbar-active", active ) ]
|
||||||
|
, attribute "aria-haspopup" "true"
|
||||||
|
, ariaExpanded model.recentMenuVisible
|
||||||
|
, Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
|
||||||
|
, Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
|
||||||
|
]
|
||||||
|
[ span [ class "navbar-dropdown" ]
|
||||||
|
[ text title
|
||||||
|
, button
|
||||||
|
[ class "navbar-dropdown-button"
|
||||||
|
, Events.onClick (RecentMenuToggled |> model.mapMsg)
|
||||||
|
]
|
||||||
|
[ i [ class "fas fa-chevron-down" ] [] ]
|
||||||
|
]
|
||||||
|
, div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
ariaExpanded : Bool -> Attribute msg
|
||||||
|
ariaExpanded value =
|
||||||
|
attribute "aria-expanded" <|
|
||||||
|
if value then
|
||||||
|
"true"
|
||||||
|
|
||||||
|
else
|
||||||
|
"false"
|
||||||
+405
@@ -0,0 +1,405 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Browser exposing (Document, UrlRequest)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Data.AppConfig as AppConfig exposing (AppConfig)
|
||||||
|
import Data.Session as Session exposing (Session)
|
||||||
|
import Html exposing (Html)
|
||||||
|
import Json.Decode as D exposing (Value)
|
||||||
|
import Layout
|
||||||
|
import Page.Home as Home
|
||||||
|
import Page.Mailbox as Mailbox
|
||||||
|
import Page.Monitor as Monitor
|
||||||
|
import Page.Status as Status
|
||||||
|
import Ports
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Task
|
||||||
|
import Time
|
||||||
|
import Url exposing (Url)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ layout : Layout.Model Msg
|
||||||
|
, page : PageModel
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type PageModel
|
||||||
|
= Home Home.Model
|
||||||
|
| Mailbox Mailbox.Model
|
||||||
|
| Monitor Monitor.Model
|
||||||
|
| Status Status.Model
|
||||||
|
|
||||||
|
|
||||||
|
type alias InitConfig =
|
||||||
|
{ appConfig : AppConfig
|
||||||
|
, session : Session.Persistent
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
|
init configValue location key =
|
||||||
|
let
|
||||||
|
configDecoder =
|
||||||
|
D.map2 InitConfig
|
||||||
|
(D.field "app-config" AppConfig.decoder)
|
||||||
|
(D.field "session" Session.decoder)
|
||||||
|
|
||||||
|
session =
|
||||||
|
case D.decodeValue configDecoder configValue of
|
||||||
|
Ok config ->
|
||||||
|
Session.init key location config.appConfig config.session
|
||||||
|
|
||||||
|
Err error ->
|
||||||
|
Session.initError key location (D.errorToString error)
|
||||||
|
|
||||||
|
( subModel, _ ) =
|
||||||
|
Home.init session
|
||||||
|
|
||||||
|
initModel =
|
||||||
|
{ layout = Layout.init LayoutMsg
|
||||||
|
, page = Home subModel
|
||||||
|
}
|
||||||
|
|
||||||
|
route =
|
||||||
|
session.router.fromUrl location
|
||||||
|
|
||||||
|
( model, cmd ) =
|
||||||
|
changeRouteTo route initModel
|
||||||
|
in
|
||||||
|
( model, Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ] )
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UrlChanged Url
|
||||||
|
| LinkClicked UrlRequest
|
||||||
|
| SessionUpdated (Result D.Error Session.Persistent)
|
||||||
|
| TimeZoneLoaded Time.Zone
|
||||||
|
| LayoutMsg Layout.Msg
|
||||||
|
| HomeMsg Home.Msg
|
||||||
|
| MailboxMsg Mailbox.Msg
|
||||||
|
| MonitorMsg Monitor.Msg
|
||||||
|
| StatusMsg Status.Msg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Sub.batch
|
||||||
|
[ pageSubscriptions model.page
|
||||||
|
, Sub.map SessionUpdated sessionChange
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
sessionChange : Sub (Result D.Error Session.Persistent)
|
||||||
|
sessionChange =
|
||||||
|
Ports.onSessionChange (D.decodeValue Session.decoder)
|
||||||
|
|
||||||
|
|
||||||
|
pageSubscriptions : PageModel -> Sub Msg
|
||||||
|
pageSubscriptions page =
|
||||||
|
case page of
|
||||||
|
Mailbox subModel ->
|
||||||
|
Sub.map MailboxMsg (Mailbox.subscriptions subModel)
|
||||||
|
|
||||||
|
Status subModel ->
|
||||||
|
Sub.map StatusMsg (Status.subscriptions subModel)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
getSession model
|
||||||
|
|
||||||
|
( newModel, cmd ) =
|
||||||
|
updateMain msg model session
|
||||||
|
|
||||||
|
newSession =
|
||||||
|
getSession newModel
|
||||||
|
in
|
||||||
|
if session.persistent == newSession.persistent then
|
||||||
|
( newModel, cmd )
|
||||||
|
|
||||||
|
else
|
||||||
|
-- Store updated persistent session.
|
||||||
|
( newModel
|
||||||
|
, Cmd.batch
|
||||||
|
[ Ports.storeSession (Session.encode newSession.persistent)
|
||||||
|
, cmd
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Handle global/navbar related msgs.
|
||||||
|
-}
|
||||||
|
updateMain : Msg -> Model -> Session -> ( Model, Cmd Msg )
|
||||||
|
updateMain msg model session =
|
||||||
|
case msg of
|
||||||
|
LinkClicked req ->
|
||||||
|
case req of
|
||||||
|
Browser.Internal url ->
|
||||||
|
case url.fragment of
|
||||||
|
Just "" ->
|
||||||
|
-- Anchor tag for accessibility purposes only, already handled.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Nav.pushUrl session.key (Url.toString url) )
|
||||||
|
|
||||||
|
Browser.External url ->
|
||||||
|
( model, Nav.load url )
|
||||||
|
|
||||||
|
UrlChanged url ->
|
||||||
|
-- Responds to new browser URL.
|
||||||
|
if session.routing then
|
||||||
|
changeRouteTo (session.router.fromUrl url) model
|
||||||
|
|
||||||
|
else
|
||||||
|
-- Skip once, but re-enable routing.
|
||||||
|
( applyToModelSession Session.enableRouting model
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionUpdated (Ok persistent) ->
|
||||||
|
( updateSession model { session | persistent = persistent }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionUpdated (Err error) ->
|
||||||
|
let
|
||||||
|
flash =
|
||||||
|
{ title = "Error decoding session"
|
||||||
|
, table = [ ( "Error", D.errorToString error ) ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
( applyToModelSession (Session.showFlash flash) model
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
TimeZoneLoaded zone ->
|
||||||
|
( updateSession model { session | zone = zone }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
LayoutMsg subMsg ->
|
||||||
|
let
|
||||||
|
( layout, newSession, cmd ) =
|
||||||
|
Layout.update subMsg model.layout session
|
||||||
|
in
|
||||||
|
( updateSession { model | layout = layout } newSession
|
||||||
|
, cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
updatePage msg model
|
||||||
|
|
||||||
|
|
||||||
|
{-| Delegate incoming messages to their respective sub-pages.
|
||||||
|
-}
|
||||||
|
updatePage : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
updatePage msg model =
|
||||||
|
case ( msg, model.page ) of
|
||||||
|
( HomeMsg subMsg, Home subModel ) ->
|
||||||
|
Home.update subMsg subModel
|
||||||
|
|> updateWith Home HomeMsg model
|
||||||
|
|
||||||
|
( MailboxMsg subMsg, Mailbox subModel ) ->
|
||||||
|
Mailbox.update subMsg subModel
|
||||||
|
|> updateWith Mailbox MailboxMsg model
|
||||||
|
|
||||||
|
( MonitorMsg subMsg, Monitor subModel ) ->
|
||||||
|
Monitor.update subMsg subModel
|
||||||
|
|> updateWith Monitor MonitorMsg model
|
||||||
|
|
||||||
|
( StatusMsg subMsg, Status subModel ) ->
|
||||||
|
Status.update subMsg subModel
|
||||||
|
|> updateWith Status StatusMsg model
|
||||||
|
|
||||||
|
( _, _ ) ->
|
||||||
|
-- Disregard messages destined for the wrong page.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
|
||||||
|
changeRouteTo route model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
getSession model |> Session.clearFlash
|
||||||
|
|
||||||
|
newModel =
|
||||||
|
{ model | layout = Layout.reset model.layout }
|
||||||
|
in
|
||||||
|
case route of
|
||||||
|
Route.Home ->
|
||||||
|
Home.init session
|
||||||
|
|> updateWith Home HomeMsg newModel
|
||||||
|
|
||||||
|
Route.Mailbox name ->
|
||||||
|
Mailbox.init session name Nothing
|
||||||
|
|> updateWith Mailbox MailboxMsg newModel
|
||||||
|
|
||||||
|
Route.Message mailbox id ->
|
||||||
|
Mailbox.init session mailbox (Just id)
|
||||||
|
|> updateWith Mailbox MailboxMsg newModel
|
||||||
|
|
||||||
|
Route.Monitor ->
|
||||||
|
if session.config.monitorVisible then
|
||||||
|
Monitor.init session
|
||||||
|
|> updateWith Monitor MonitorMsg newModel
|
||||||
|
|
||||||
|
else
|
||||||
|
let
|
||||||
|
flash =
|
||||||
|
{ title = "Disabled route requested"
|
||||||
|
, table = [ ( "Error", "Monitor disabled by configuration." ) ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
( applyToModelSession (Session.showFlash flash) newModel
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
Route.Status ->
|
||||||
|
Status.init session
|
||||||
|
|> updateWith Status StatusMsg newModel
|
||||||
|
|
||||||
|
Route.Unknown path ->
|
||||||
|
-- Unknown routes display Home with an error flash.
|
||||||
|
let
|
||||||
|
flash =
|
||||||
|
{ title = "Unknown route requested"
|
||||||
|
, table = [ ( "Path", path ) ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Home.init (Session.showFlash flash session)
|
||||||
|
|> updateWith Home HomeMsg newModel
|
||||||
|
|
||||||
|
|
||||||
|
getSession : Model -> Session
|
||||||
|
getSession model =
|
||||||
|
case model.page of
|
||||||
|
Home subModel ->
|
||||||
|
subModel.session
|
||||||
|
|
||||||
|
Mailbox subModel ->
|
||||||
|
subModel.session
|
||||||
|
|
||||||
|
Monitor subModel ->
|
||||||
|
subModel.session
|
||||||
|
|
||||||
|
Status subModel ->
|
||||||
|
subModel.session
|
||||||
|
|
||||||
|
|
||||||
|
updateSession : Model -> Session -> Model
|
||||||
|
updateSession model session =
|
||||||
|
case model.page of
|
||||||
|
Home subModel ->
|
||||||
|
{ model | page = Home { subModel | session = session } }
|
||||||
|
|
||||||
|
Mailbox subModel ->
|
||||||
|
{ model | page = Mailbox { subModel | session = session } }
|
||||||
|
|
||||||
|
Monitor subModel ->
|
||||||
|
{ model | page = Monitor { subModel | session = session } }
|
||||||
|
|
||||||
|
Status subModel ->
|
||||||
|
{ model | page = Status { subModel | session = session } }
|
||||||
|
|
||||||
|
|
||||||
|
applyToModelSession : (Session -> Session) -> Model -> Model
|
||||||
|
applyToModelSession f model =
|
||||||
|
updateSession model (f (getSession model))
|
||||||
|
|
||||||
|
|
||||||
|
{-| Map page updates to Main Model and Msg types.
|
||||||
|
-}
|
||||||
|
updateWith :
|
||||||
|
(subModel -> PageModel)
|
||||||
|
-> (subMsg -> Msg)
|
||||||
|
-> Model
|
||||||
|
-> ( subModel, Cmd subMsg )
|
||||||
|
-> ( Model, Cmd Msg )
|
||||||
|
updateWith toPage toMsg model ( subModel, subCmd ) =
|
||||||
|
( { model | page = toPage subModel }
|
||||||
|
, Cmd.map toMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Document Msg
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
getSession model
|
||||||
|
|
||||||
|
mailbox =
|
||||||
|
case model.page of
|
||||||
|
Mailbox subModel ->
|
||||||
|
subModel.mailboxName
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
""
|
||||||
|
|
||||||
|
framePage :
|
||||||
|
Layout.Page
|
||||||
|
-> (msg -> Msg)
|
||||||
|
-> { title : String, modal : Maybe (Html msg), content : List (Html msg) }
|
||||||
|
-> Document Msg
|
||||||
|
framePage page toMsg { title, modal, content } =
|
||||||
|
Document title
|
||||||
|
[ Layout.frame
|
||||||
|
{ model = model.layout
|
||||||
|
, session = session
|
||||||
|
, activePage = page
|
||||||
|
, activeMailbox = mailbox
|
||||||
|
, modal = Maybe.map (Html.map toMsg) modal
|
||||||
|
, content = List.map (Html.map toMsg) content
|
||||||
|
}
|
||||||
|
]
|
||||||
|
in
|
||||||
|
case model.page of
|
||||||
|
Home subModel ->
|
||||||
|
framePage Layout.Other HomeMsg (Home.view subModel)
|
||||||
|
|
||||||
|
Mailbox subModel ->
|
||||||
|
framePage Layout.Mailbox MailboxMsg (Mailbox.view subModel)
|
||||||
|
|
||||||
|
Monitor subModel ->
|
||||||
|
framePage Layout.Monitor MonitorMsg (Monitor.view subModel)
|
||||||
|
|
||||||
|
Status subModel ->
|
||||||
|
framePage Layout.Status StatusMsg (Status.view subModel)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program Value Model Msg
|
||||||
|
main =
|
||||||
|
Browser.application
|
||||||
|
{ init = init
|
||||||
|
, view = view
|
||||||
|
, update = update
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
, onUrlChange = UrlChanged
|
||||||
|
, onUrlRequest = LinkClicked
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
module Modal exposing (Msg, resetFocusCmd, updateSession, view)
|
||||||
|
|
||||||
|
import Browser.Dom as Dom
|
||||||
|
import Data.Session as Session exposing (Session)
|
||||||
|
import Html exposing (Html, div, span, text)
|
||||||
|
import Html.Attributes exposing (class, id, tabindex)
|
||||||
|
import Html.Events exposing (onFocus)
|
||||||
|
import Task
|
||||||
|
|
||||||
|
|
||||||
|
type alias Msg =
|
||||||
|
Result Dom.Error ()
|
||||||
|
|
||||||
|
|
||||||
|
{-| Creates a command to focus the modal dialog.
|
||||||
|
-}
|
||||||
|
resetFocusCmd : (Msg -> msg) -> Cmd msg
|
||||||
|
resetFocusCmd resultMsg =
|
||||||
|
Task.attempt resultMsg (Dom.focus domId)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Updates a Session with an error Flash if the resetFocusCmd failed.
|
||||||
|
-}
|
||||||
|
updateSession : Msg -> Session -> Session
|
||||||
|
updateSession result session =
|
||||||
|
case result of
|
||||||
|
Ok () ->
|
||||||
|
session
|
||||||
|
|
||||||
|
Err (Dom.NotFound missingDomId) ->
|
||||||
|
let
|
||||||
|
flash =
|
||||||
|
{ title = "DOM element not found"
|
||||||
|
, table = [ ( "Element ID", missingDomId ) ]
|
||||||
|
}
|
||||||
|
in
|
||||||
|
Session.showFlash flash session
|
||||||
|
|
||||||
|
|
||||||
|
view : msg -> Maybe (Html msg) -> Html msg
|
||||||
|
view unfocusedMsg maybeModal =
|
||||||
|
case maybeModal of
|
||||||
|
Just modal ->
|
||||||
|
div [ class "modal-mask" ]
|
||||||
|
[ span [ onFocus unfocusedMsg, tabindex 0 ] []
|
||||||
|
, div [ id domId, class "modal well", tabindex -1 ] [ modal ]
|
||||||
|
, span [ onFocus unfocusedMsg, tabindex 0 ] []
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
|
||||||
|
{-| DOM ID of the modal dialog.
|
||||||
|
-}
|
||||||
|
domId : String
|
||||||
|
domId =
|
||||||
|
"modal-dialog"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user