1
0
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:
James Hillyerd
2018-10-31 20:07:34 -07:00
37 changed files with 1031 additions and 400 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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 /

View File

@@ -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]
![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message") ![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message")
@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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"
} }

View File

@@ -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
View 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
View 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=

View File

@@ -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.

View File

@@ -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,

View File

@@ -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)

View File

@@ -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))
}

View 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()
}
}

View File

@@ -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")
} }

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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,
} }
} }

View File

@@ -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,

View File

@@ -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,
} }

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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]

View File

@@ -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
} }

View File

@@ -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))

View 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
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
From: From User <fromuser@inbucket.org>
To: "Rec I. Pient" <recipient@inbucket.org>
Subject: basic subject
Basic message.

View File

@@ -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>

View File

@@ -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>