mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-01-28 22:15:56 +00:00
Merge branch 'release/2.1.0-beta1'
This commit is contained in:
@@ -7,14 +7,17 @@ addons:
|
|||||||
- rpm
|
- rpm
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- DEPLOY_WITH_MAJOR="1.10"
|
global:
|
||||||
|
- GO111MODULE=on
|
||||||
|
- DEPLOY_WITH_MAJOR="1.11"
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- go get github.com/golang/lint/golint
|
- go get golang.org/x/lint/golint
|
||||||
- make deps
|
- make deps
|
||||||
|
|
||||||
go:
|
go:
|
||||||
- "1.10.1"
|
- "1.10.x"
|
||||||
|
- "1.11.x"
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
provider: script
|
provider: script
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -4,12 +4,31 @@ 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/).
|
||||||
|
|
||||||
|
|
||||||
|
## [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
|
||||||
@@ -160,6 +179,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
specific message.
|
specific message.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||||
|
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
|
||||||
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||||
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
|
||||||
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM golang:1.10-alpine as builder
|
FROM golang:1.11-alpine3.8 as builder
|
||||||
RUN apk add --no-cache --virtual .build-deps git make
|
RUN apk add --no-cache --virtual .build-deps git make
|
||||||
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
|
||||||
@@ -12,11 +12,10 @@ RUN go build -o inbucket \
|
|||||||
-v ./cmd/inbucket
|
-v ./cmd/inbucket
|
||||||
|
|
||||||
# Run in minimal image
|
# Run in minimal image
|
||||||
FROM alpine:3.7
|
FROM alpine:3.8
|
||||||
ENV SRC /go/src/github.com/jhillyerd/inbucket
|
|
||||||
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 etc/docker/defaults/greeting.html defaults
|
COPY etc/docker/defaults/greeting.html defaults
|
||||||
COPY ui ui
|
COPY ui ui
|
||||||
COPY etc/docker/defaults/start-inbucket.sh /
|
COPY etc/docker/defaults/start-inbucket.sh /
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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/jhillyerd/inbucket/pkg/rest/client` - [Go API docs]
|
||||||
|
|
||||||
Read more at the [Inbucket Website]
|
Read more at the [Inbucket Website]
|
||||||
|
|
||||||

|

|
||||||
@@ -55,6 +58,7 @@ Inbucket is written in [Google 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/jhillyerd/inbucket
|
||||||
|
|
||||||
|
[Go API docs]: https://godoc.org/github.com/jhillyerd/inbucket/pkg/rest/client
|
||||||
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
|
||||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
||||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func main() {
|
|||||||
retentionScanner.Start()
|
retentionScanner.Start()
|
||||||
// Start HTTP server.
|
// Start HTTP server.
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
rest.SetupRoutes(web.Router)
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
webui.SetupRoutes(web.Router)
|
webui.SetupRoutes(web.Router)
|
||||||
go web.Start(rootCtx)
|
go web.Start(rootCtx)
|
||||||
// Start POP3 server.
|
// Start POP3 server.
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ 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
|
||||||
@@ -32,6 +35,7 @@ variables it supports:
|
|||||||
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
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
|
||||||
@@ -202,6 +206,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_TLSPRIVKEY`
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -344,6 +378,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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==
|
||||||
|
|||||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module github.com/jhillyerd/inbucket
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
|
||||||
|
github.com/gorilla/css v1.0.0
|
||||||
|
github.com/gorilla/mux v1.6.2
|
||||||
|
github.com/gorilla/securecookie v1.1.1
|
||||||
|
github.com/gorilla/sessions v1.1.3
|
||||||
|
github.com/gorilla/websocket v1.4.0
|
||||||
|
github.com/jhillyerd/enmime v0.2.1
|
||||||
|
github.com/jhillyerd/goldiff v0.1.0
|
||||||
|
github.com/kelseyhightower/envconfig v1.3.0
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.1
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rs/zerolog v1.9.1
|
||||||
|
github.com/stretchr/testify v1.2.2
|
||||||
|
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f
|
||||||
|
)
|
||||||
42
go.sum
Normal file
42
go.sum
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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/google/subcommands v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
|
||||||
|
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||||
|
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||||
|
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.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
|
||||||
|
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
|
||||||
|
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||||
|
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||||
|
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/jhillyerd/enmime v0.2.1 h1:YodBfMH3jmrZn68Gg4ZoZH1ECDsdh8BLW9+DjoFce6o=
|
||||||
|
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
|
||||||
|
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.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
|
||||||
|
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
|
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
|
||||||
|
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||||
|
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/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
|
||||||
|
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||||
|
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
|
||||||
|
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -76,6 +76,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ type Web struct {
|
|||||||
CookieAuthKey string `desc:"Session cipher key (text)"`
|
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.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
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,
|
||||||
@@ -79,7 +79,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
&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,
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
@@ -139,7 +137,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 +147,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 +157,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)
|
||||||
@@ -201,7 +199,7 @@ func TestRestMessage(t *testing.T) {
|
|||||||
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)
|
||||||
@@ -264,7 +262,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 +271,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)
|
||||||
|
|||||||
@@ -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/jhillyerd/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))
|
||||||
|
}
|
||||||
|
|||||||
102
pkg/rest/client/example_test.go
Normal file
102
pkg/rest/client/example_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/jhillyerd/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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,20 @@ import "github.com/jhillyerd/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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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",
|
||||||
@@ -51,7 +50,7 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
|||||||
}
|
}
|
||||||
shutdownChan := make(chan bool)
|
shutdownChan := make(chan bool)
|
||||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||||
SetupRoutes(web.Router)
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package smtp
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"net/textproto"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -73,6 +75,7 @@ var commands = map[string]bool{
|
|||||||
"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 {
|
||||||
@@ -367,9 +401,7 @@ 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 {
|
|
||||||
lineBuf, err := s.readByteLine()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if netErr, ok := err.(net.Error); ok {
|
if netErr, ok := err.(net.Error); ok {
|
||||||
if netErr.Timeout() {
|
if netErr.Timeout() {
|
||||||
@@ -380,7 +412,8 @@ func (s *Session) dataHandler() {
|
|||||||
s.enterState(QUIT)
|
s.enterState(QUIT)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
|
mailData := bytes.NewBuffer(msgBuf)
|
||||||
|
|
||||||
// Mail data complete.
|
// Mail data complete.
|
||||||
tstamp := time.Now().Format(timeStampFormat)
|
tstamp := time.Now().Format(timeStampFormat)
|
||||||
for _, recip := range s.recipients {
|
for _, recip := range s.recipients {
|
||||||
@@ -391,7 +424,7 @@ func (s *Session) dataHandler() {
|
|||||||
tstamp)
|
tstamp)
|
||||||
// Deliver message.
|
// Deliver message.
|
||||||
_, err := s.manager.Deliver(
|
_, err := s.manager.Deliver(
|
||||||
recip, s.from, s.recipients, prefix, msgBuf.Bytes())
|
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
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.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||||
@@ -402,22 +435,9 @@ func (s *Session) dataHandler() {
|
|||||||
expReceivedTotal.Add(1)
|
expReceivedTotal.Add(1)
|
||||||
}
|
}
|
||||||
s.send("250 Mail accepted for delivery")
|
s.send("250 Mail accepted for delivery")
|
||||||
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
|
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
|
||||||
s.reset()
|
s.reset()
|
||||||
return
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) enterState(state State) {
|
func (s *Session) enterState(state State) {
|
||||||
@@ -440,7 +460,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 +470,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,7 +509,7 @@ 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
|
||||||
@@ -513,7 +536,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 {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package smtp
|
|||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"expvar"
|
"expvar"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||||
var TemplateFuncs = template.FuncMap{
|
var TemplateFuncs = template.FuncMap{
|
||||||
|
"address": stringutil.StringAddress,
|
||||||
"friendlyTime": FriendlyTime,
|
"friendlyTime": FriendlyTime,
|
||||||
"reverse": Reverse,
|
"reverse": Reverse,
|
||||||
"stringsJoin": strings.Join,
|
"stringsJoin": strings.Join,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"expvar"
|
"expvar"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -70,7 +71,16 @@ func Initialize(
|
|||||||
Msg("Web UI content mapped")
|
Msg("Web UI content mapped")
|
||||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||||
http.FileServer(http.Dir(staticPath))))
|
http.FileServer(http.Dir(staticPath))))
|
||||||
http.Handle("/", Router)
|
Router.Handle("/debug/vars", expvar.Handler())
|
||||||
|
if conf.Web.PProf {
|
||||||
|
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
|
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
|
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
|
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
|
||||||
|
log.Warn().Str("module", "web").Str("phase", "startup").
|
||||||
|
Msg("Go pprof tools installed to /debug/pprof")
|
||||||
|
}
|
||||||
|
|
||||||
// Session cookie setup
|
// Session cookie setup
|
||||||
if conf.Web.CookieAuthKey == "" {
|
if conf.Web.CookieAuthKey == "" {
|
||||||
@@ -88,7 +98,7 @@ func Initialize(
|
|||||||
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: Router,
|
||||||
ReadTimeout: 60 * time.Second,
|
ReadTimeout: 60 * time.Second,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ 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/jhillyerd/inbucket/pkg/config"
|
||||||
@@ -44,6 +44,7 @@ type Store struct {
|
|||||||
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,30 +189,25 @@ 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()
|
|
||||||
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Loop over level 2 directories
|
// Loop over level 2 directories
|
||||||
for _, inf2 := range infos2 {
|
for _, name2 := range names2 {
|
||||||
if inf2.IsDir() {
|
names3, err := readDirNames(fs.mailPath, name1, name2)
|
||||||
l2 := inf2.Name()
|
|
||||||
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Loop over mailboxes
|
// Loop over mailboxes
|
||||||
for _, inf3 := range infos3 {
|
for _, name3 := range names3 {
|
||||||
if inf3.IsDir() {
|
mb := fs.mboxFromHash(name3)
|
||||||
mb := fs.mboxFromHash(inf3.Name())
|
|
||||||
mb.RLock()
|
mb.RLock()
|
||||||
msgs, err := mb.getMessages()
|
msgs, err := mb.getMessages()
|
||||||
mb.RUnlock()
|
mb.RUnlock()
|
||||||
@@ -215,9 +220,6 @@ func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,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))
|
||||||
|
|||||||
203
pkg/test/integration_test.go
Normal file
203
pkg/test/integration_test.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
smtpclient "net/smtp"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jhillyerd/goldiff"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/config"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/message"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/msghub"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/policy"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/rest"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/rest/client"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/smtp"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/storage/mem"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
_ = stopServer
|
||||||
|
// 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.
|
||||||
|
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.
|
||||||
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
|
webui.SetupRoutes(web.Router)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
|||||||
12
pkg/test/testdata/basic.golden
vendored
Normal file
12
pkg/test/testdata/basic.golden
vendored
Normal file
@@ -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:
|
||||||
|
|
||||||
5
pkg/test/testdata/basic.txt
vendored
Normal file
5
pkg/test/testdata/basic.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
From: fromuser@inbucket.org
|
||||||
|
To: recipient@inbucket.org
|
||||||
|
Subject: basic subject
|
||||||
|
|
||||||
|
Basic message.
|
||||||
12
pkg/test/testdata/encodedheader.golden
vendored
Normal file
12
pkg/test/testdata/encodedheader.golden
vendored
Normal file
@@ -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:
|
||||||
|
|
||||||
5
pkg/test/testdata/encodedheader.txt
vendored
Normal file
5
pkg/test/testdata/encodedheader.txt
vendored
Normal file
@@ -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.
|
||||||
12
pkg/test/testdata/fullname.golden
vendored
Normal file
12
pkg/test/testdata/fullname.golden
vendored
Normal file
@@ -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:
|
||||||
|
|
||||||
5
pkg/test/testdata/fullname.txt
vendored
Normal file
5
pkg/test/testdata/fullname.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
From: From User <fromuser@inbucket.org>
|
||||||
|
To: "Rec I. Pient" <recipient@inbucket.org>
|
||||||
|
Subject: basic subject
|
||||||
|
|
||||||
|
Basic message.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -51,12 +51,12 @@
|
|||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<dl class="dl-horizontal">
|
<dl class="dl-horizontal">
|
||||||
<dt>From:</dt>
|
<dt>From:</dt>
|
||||||
<dd>{{.message.From}}</dd>
|
<dd>{{.message.From | address}}</dd>
|
||||||
<dt>To:</dt>
|
<dt>To:</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{{range $i, $addr := .message.To}}
|
{{range $i, $addr := .message.To}}
|
||||||
{{- if $i}},{{end}}
|
{{- if $i}},{{end}}
|
||||||
{{$addr -}}
|
{{$addr | address -}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Date:</dt>
|
<dt>Date:</dt>
|
||||||
|
|||||||
Reference in New Issue
Block a user