mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-22 20:17:02 +00:00
Merge branch 'release/2.1.0-beta1'
This commit is contained in:
@@ -7,14 +7,17 @@ addons:
|
||||
- rpm
|
||||
|
||||
env:
|
||||
- DEPLOY_WITH_MAJOR="1.10"
|
||||
global:
|
||||
- GO111MODULE=on
|
||||
- DEPLOY_WITH_MAJOR="1.11"
|
||||
|
||||
before_script:
|
||||
- go get github.com/golang/lint/golint
|
||||
- go get golang.org/x/lint/golint
|
||||
- make deps
|
||||
|
||||
go:
|
||||
- "1.10.1"
|
||||
- "1.10.x"
|
||||
- "1.11.x"
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -4,12 +4,31 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
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
|
||||
|
||||
### Changed
|
||||
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
|
||||
- Disabled color log output on Windows, doesn't work there.
|
||||
|
||||
|
||||
## [v2.0.0-rc1] - 2018-04-07
|
||||
|
||||
### Added
|
||||
@@ -160,7 +179,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
|
||||
[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-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.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Docker build file for Inbucket: https://www.inbucket.org/
|
||||
|
||||
# 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
|
||||
WORKDIR /go/src/github.com/jhillyerd/inbucket
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
ENV CGO_ENABLED 0
|
||||
RUN make clean deps
|
||||
@@ -12,11 +12,10 @@ RUN go build -o inbucket \
|
||||
-v ./cmd/inbucket
|
||||
|
||||
# Run in minimal image
|
||||
FROM alpine:3.7
|
||||
ENV SRC /go/src/github.com/jhillyerd/inbucket
|
||||
FROM alpine:3.8
|
||||
WORKDIR /opt/inbucket
|
||||
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 ui ui
|
||||
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
|
||||
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]
|
||||
|
||||

|
||||
@@ -55,6 +58,7 @@ Inbucket is written in [Google Go]
|
||||
Inbucket is open source software released under the MIT License. The latest
|
||||
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
|
||||
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
|
||||
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
|
||||
|
||||
@@ -125,7 +125,7 @@ func main() {
|
||||
retentionScanner.Start()
|
||||
// Start HTTP server.
|
||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||
rest.SetupRoutes(web.Router)
|
||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
webui.SetupRoutes(web.Router)
|
||||
go web.Start(rootCtx)
|
||||
// Start POP3 server.
|
||||
|
||||
@@ -21,6 +21,9 @@ variables it supports:
|
||||
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
|
||||
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
|
||||
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
|
||||
INBUCKET_SMTP_TLSENABLED false Enable STARTTLS option
|
||||
INBUCKET_SMTP_TLSPRIVKEY cert.key X509 Private Key file for TLS Support
|
||||
INBUCKET_SMTP_TLSCERT cert.crt X509 Public Certificate file for TLS Support
|
||||
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
|
||||
INBUCKET_POP3_DOMAIN inbucket HELLO domain
|
||||
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
|
||||
@@ -32,6 +35,7 @@ variables it supports:
|
||||
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
|
||||
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
|
||||
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
|
||||
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
|
||||
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
|
||||
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
|
||||
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
|
||||
@@ -202,6 +206,36 @@ to the public internet.
|
||||
- Default: `300s`
|
||||
- Values: Duration ending in `s` for seconds, `m` for minutes
|
||||
|
||||
### TLS Support Availability
|
||||
|
||||
`INBUCKET_SMTP_TLSENABLED`
|
||||
|
||||
Enable the STARTTLS option for opportunistic TLS support
|
||||
|
||||
- Default: `false`
|
||||
- Values: `true` or `false`
|
||||
|
||||
### TLS Private Key File
|
||||
|
||||
`INBUCKET_SMTP_TLSPRIVKEY`
|
||||
|
||||
Specify the x509 Private key file to be used for TLS negotiation.
|
||||
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
|
||||
|
||||
- Default: `cert.key`
|
||||
- Values: filename or path to private key
|
||||
- Example: `server.privkey`
|
||||
|
||||
### TLS Public Certificate File
|
||||
|
||||
`INBUCKET_SMTP_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
|
||||
|
||||
@@ -344,6 +378,20 @@ them.
|
||||
- Default: `30`
|
||||
- Values: Integer greater than or equal to 0
|
||||
|
||||
### Performance Profiling & Debug Tools
|
||||
|
||||
`INBUCKET_WEB_PPROF`
|
||||
|
||||
If true, Go's pprof package will be installed to the `/debug/pprof` URI. This
|
||||
exposes detailed memory and CPU performance data for debugging Inbucket. If you
|
||||
enable this option, please make sure it is not exposed to the public internet,
|
||||
as its use can significantly impact performance.
|
||||
|
||||
For example usage, see https://golang.org/pkg/net/http/pprof/
|
||||
|
||||
- Default: `false`
|
||||
- Values: `true` or `false`
|
||||
|
||||
|
||||
## Storage
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<p>Inbucket is an email testing service; it will accept email for any email
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
<p>To view email for a particular address, enter the username portion
|
||||
<p>To view messages for a particular address, enter the username portion
|
||||
of the address into the box on the upper right and click <em>View</em>.</p>
|
||||
|
||||
<p>This instance of Inbucket is running inside of a <a
|
||||
|
||||
@@ -12,9 +12,9 @@ PORT_POP3=1100
|
||||
|
||||
# Volumes exposed on host:
|
||||
VOL_CONFIG="/tmp/inbucket/config"
|
||||
VOL_DATA="/tmp/inbucket/data"
|
||||
VOL_DATA="/tmp/inbucket/storage"
|
||||
|
||||
set -eo pipefail
|
||||
set -e
|
||||
|
||||
main() {
|
||||
local run_opts=""
|
||||
@@ -39,11 +39,11 @@ main() {
|
||||
done
|
||||
|
||||
docker run $run_opts \
|
||||
-p $PORT_HTTP:10080 \
|
||||
-p $PORT_SMTP:10025 \
|
||||
-p $PORT_POP3:10110 \
|
||||
-v "$VOL_CONFIG:/con/configuration" \
|
||||
-v "$VOL_DATA:/con/data" \
|
||||
-p $PORT_HTTP:9000 \
|
||||
-p $PORT_SMTP:2500 \
|
||||
-p $PORT_POP3:1100 \
|
||||
-v "$VOL_CONFIG:/config" \
|
||||
-v "$VOL_DATA:/storage" \
|
||||
"$IMAGE"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
To: %TO_ADDRESS%,
|
||||
=?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
|
||||
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?=
|
||||
<fromuser@inbucket.org>
|
||||
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
|
||||
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==
|
||||
|
||||
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"`
|
||||
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
||||
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||
Debug bool `ignored:"true"`
|
||||
}
|
||||
|
||||
@@ -97,6 +100,7 @@ type Web struct {
|
||||
CookieAuthKey string `desc:"Session cipher key (text)"`
|
||||
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
|
||||
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
|
||||
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`
|
||||
}
|
||||
|
||||
// Storage contains the mail store configuration.
|
||||
|
||||
@@ -33,7 +33,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: msg.From.String(),
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
@@ -79,7 +79,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
||||
&model.JSONMessageV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID,
|
||||
From: msg.From.String(),
|
||||
From: stringutil.StringAddress(msg.From),
|
||||
To: stringutil.StringAddressList(msg.To),
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://localhost/api/v1"
|
||||
|
||||
// JSON map keys
|
||||
mailboxKey = "mailbox"
|
||||
idKey = "id"
|
||||
@@ -37,7 +35,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
logbuf := setupWebServer(mm)
|
||||
|
||||
// Test invalid mailbox name
|
||||
w, err := testRestGet(baseURL + "/mailbox/foo%20bar")
|
||||
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
|
||||
expectCode := 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -47,7 +45,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test empty mailbox
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/empty")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -57,7 +55,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test Mailbox error
|
||||
w, err = testRestGet(baseURL + "/mailbox/messageserr")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/messageserr")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -89,7 +87,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
mm.AddMessage("good", &message.Message{Metadata: meta2})
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -139,7 +137,7 @@ func TestRestMessage(t *testing.T) {
|
||||
logbuf := setupWebServer(mm)
|
||||
|
||||
// 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
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -149,7 +147,7 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -159,7 +157,7 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test GetMessage error
|
||||
w, err = testRestGet(baseURL + "/mailbox/messageerr/0001")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/messageerr/0001")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -201,7 +199,7 @@ func TestRestMessage(t *testing.T) {
|
||||
mm.AddMessage("good", msg1)
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good/0001")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good/0001")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
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: meta2})
|
||||
// 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
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -273,7 +271,7 @@ func TestRestMarkSeen(t *testing.T) {
|
||||
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
// Get mailbox.
|
||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
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) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
_, _ = c.ListMailbox("testbox")
|
||||
if len(headers) != 1 {
|
||||
t.Fatalf("Got %v headers, want 1", len(headers))
|
||||
}
|
||||
h := headers[0]
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
got := h.Mailbox
|
||||
want := "testbox"
|
||||
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 = mth.req.URL.String()
|
||||
got = h.ID
|
||||
want = "1"
|
||||
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) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// 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)
|
||||
if m == nil {
|
||||
t.Fatalf("message was nil, wanted a value")
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
||||
got = mth.req.URL.String()
|
||||
got := m.Mailbox
|
||||
want := "testbox"
|
||||
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) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
_ = c.MarkSeen("testbox", "20170107T224128-0000")
|
||||
|
||||
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)
|
||||
if !handler.called {
|
||||
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1GetMessageSource(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{
|
||||
body: "message source",
|
||||
}
|
||||
c.client = mth
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
|
||||
Handler(&jsonHandler{json: `message source`})
|
||||
|
||||
// Method under test
|
||||
// Method under test.
|
||||
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
want := "message source"
|
||||
got := source.String()
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %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)
|
||||
t.Errorf("Source got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1DeleteMessage(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("DELETE").
|
||||
Handler(handler)
|
||||
|
||||
// Method under test
|
||||
err = c.DeleteMessage("testbox", "20170107T224128-0000")
|
||||
// Method under test.
|
||||
err := c.DeleteMessage("testbox", "20170107T224128-0000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "DELETE"
|
||||
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)
|
||||
if !handler.called {
|
||||
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1PurgeMailbox(t *testing.T) {
|
||||
var want, got string
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/testbox").Methods("DELETE").Handler(handler)
|
||||
|
||||
// Method under test
|
||||
err = c.PurgeMailbox("testbox")
|
||||
// Method under test.
|
||||
err := c.PurgeMailbox("testbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "DELETE"
|
||||
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 !handler.called {
|
||||
t.Error("Wanted HTTP handler to be called, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1MessageHeader(t *testing.T) {
|
||||
var want, got string
|
||||
response := `[
|
||||
// Setup.
|
||||
c, router, teardown := setup()
|
||||
defer teardown()
|
||||
|
||||
listHandler := &jsonHandler{json: `[
|
||||
{
|
||||
"mailbox":"mailbox1",
|
||||
"id":"id1",
|
||||
@@ -187,115 +263,52 @@ func TestClientV1MessageHeader(t *testing.T) {
|
||||
"size":100,
|
||||
"seen":true
|
||||
}
|
||||
]`
|
||||
]`}
|
||||
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{body: response}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
// Method under test.
|
||||
headers, err := c.ListMailbox("testbox")
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("len(headers) == %v, want 1", len(headers))
|
||||
}
|
||||
header := headers[0]
|
||||
|
||||
want = "mailbox1"
|
||||
got = header.Mailbox
|
||||
if got != want {
|
||||
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 = ""
|
||||
// Test MessageHeader.Delete().
|
||||
handler := &jsonHandler{}
|
||||
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("DELETE").Handler(handler)
|
||||
err = header.Delete()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "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 MessageHeader.GetSource()
|
||||
mth.body = "source1"
|
||||
_, err = header.GetSource()
|
||||
// Test MessageHeader.GetSource().
|
||||
router.Path("/api/v1/mailbox/mailbox1/id1/source").Methods("GET").
|
||||
Handler(&jsonHandler{json: `source1`})
|
||||
buf, err := header.GetSource()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
want := "source1"
|
||||
got := buf.String()
|
||||
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)
|
||||
}
|
||||
|
||||
// Test MessageHeader.GetMessage()
|
||||
mth.body = `{
|
||||
// Test MessageHeader.GetMessage().
|
||||
messageHandler := &jsonHandler{json: `{
|
||||
"mailbox":"mailbox1",
|
||||
"id":"id1",
|
||||
"from":"from1",
|
||||
"subject":"subject1",
|
||||
"date":"2017-01-01T00:00:00.000-07:00",
|
||||
"size":100
|
||||
}`
|
||||
}`}
|
||||
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("GET").Handler(messageHandler)
|
||||
message, err := header.GetMessage()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -304,53 +317,45 @@ func TestClientV1MessageHeader(t *testing.T) {
|
||||
t.Fatalf("message was nil, wanted a value")
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
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 = ""
|
||||
// Test Message.Delete().
|
||||
err = message.Delete()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "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 MessageHeader.GetSource()
|
||||
mth.body = "source1"
|
||||
_, err = message.GetSource()
|
||||
// Test Message.GetSource().
|
||||
buf, err = message.GetSource()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "GET"
|
||||
got = mth.req.Method
|
||||
want = "source1"
|
||||
got = buf.String()
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %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)
|
||||
t.Errorf("Got source %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// setup returns a client, router and server for API testing.
|
||||
func setup() (c *client.Client, router *mux.Router, teardown func()) {
|
||||
router = mux.NewRouter()
|
||||
server := httptest.NewServer(router)
|
||||
c, err := client.New(server.URL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return c, router, func() {
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// jsonHandler returns the string in json when servicing a request.
|
||||
type jsonHandler struct {
|
||||
json string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
j.called = true
|
||||
w.Write([]byte(j.json))
|
||||
}
|
||||
|
||||
102
pkg/rest/client/example_test.go
Normal file
102
pkg/rest/client/example_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/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
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
// API v1
|
||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||
r.Path("/v1/mailbox/{name}").Handler(
|
||||
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")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||
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")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
r.Path("/v1/mailbox/{name}/{id}").Handler(
|
||||
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")
|
||||
r.Path("/api/v1/monitor/messages").Handler(
|
||||
r.Path("/v1/monitor/messages").Handler(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||
log.SetOutput(buf)
|
||||
|
||||
// Have to reset default mux to prevent duplicate routes
|
||||
http.DefaultServeMux = http.NewServeMux()
|
||||
cfg := &config.Root{
|
||||
Web: config.Web{
|
||||
UIDir: "../ui",
|
||||
@@ -51,7 +50,7 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||
SetupRoutes(web.Router)
|
||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package smtp
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -58,21 +60,22 @@ func (s State) String() string {
|
||||
}
|
||||
|
||||
var commands = map[string]bool{
|
||||
"HELO": true,
|
||||
"EHLO": true,
|
||||
"MAIL": true,
|
||||
"RCPT": true,
|
||||
"DATA": true,
|
||||
"RSET": true,
|
||||
"SEND": true,
|
||||
"SOML": true,
|
||||
"SAML": true,
|
||||
"VRFY": true,
|
||||
"EXPN": true,
|
||||
"HELP": true,
|
||||
"NOOP": true,
|
||||
"QUIT": true,
|
||||
"TURN": true,
|
||||
"HELO": true,
|
||||
"EHLO": true,
|
||||
"MAIL": true,
|
||||
"RCPT": true,
|
||||
"DATA": true,
|
||||
"RSET": true,
|
||||
"SEND": true,
|
||||
"SOML": true,
|
||||
"SAML": true,
|
||||
"VRFY": true,
|
||||
"EXPN": true,
|
||||
"HELP": true,
|
||||
"NOOP": true,
|
||||
"QUIT": true,
|
||||
"TURN": true,
|
||||
"STARTTLS": true,
|
||||
}
|
||||
|
||||
// Session holds the state of an SMTP session
|
||||
@@ -89,12 +92,15 @@ type Session struct {
|
||||
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||
logger zerolog.Logger // Session specific logger.
|
||||
debug bool // Print network traffic to stdout.
|
||||
tlsState *tls.ConnectionState
|
||||
text *textproto.Conn
|
||||
}
|
||||
|
||||
// NewSession creates a new Session for the given connection
|
||||
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
|
||||
reader := bufio.NewReader(conn)
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
|
||||
return &Session{
|
||||
Server: server,
|
||||
id: id,
|
||||
@@ -105,6 +111,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
|
||||
recipients: make([]*policy.Recipient, 0),
|
||||
logger: logger,
|
||||
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
|
||||
func (s *Session) greetHandler(cmd string, arg string) {
|
||||
const readyBanner = "Great, let's get this show on the road"
|
||||
switch cmd {
|
||||
case "HELO":
|
||||
domain, err := parseHelloArgument(arg)
|
||||
@@ -240,7 +248,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
s.remoteDomain = domain
|
||||
s.send("250 Great, let's get this show on the road")
|
||||
s.send("250 " + readyBanner)
|
||||
s.enterState(READY)
|
||||
case "EHLO":
|
||||
domain, err := parseHelloArgument(arg)
|
||||
@@ -249,8 +257,12 @@ func (s *Session) greetHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
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")
|
||||
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.enterState(READY)
|
||||
default:
|
||||
@@ -271,7 +283,29 @@ func parseHelloArgument(arg string) (string, error) {
|
||||
|
||||
// READY state -> waiting for MAIL
|
||||
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.
|
||||
m := fromRegex.FindStringSubmatch(arg)
|
||||
if m == nil {
|
||||
@@ -367,57 +401,43 @@ func (s *Session) mailHandler(cmd string, arg string) {
|
||||
// DATA
|
||||
func (s *Session) dataHandler() {
|
||||
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
||||
msgBuf := &bytes.Buffer{}
|
||||
for {
|
||||
lineBuf, err := s.readByteLine()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
s.send("221 Idle timeout, bye bye")
|
||||
}
|
||||
msgBuf, err := s.readDataBlock()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
s.send("221 Idle timeout, bye bye")
|
||||
}
|
||||
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||
s.enterState(QUIT)
|
||||
return
|
||||
}
|
||||
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
|
||||
// Mail data complete.
|
||||
tstamp := time.Now().Format(timeStampFormat)
|
||||
for _, recip := range s.recipients {
|
||||
if recip.ShouldStore() {
|
||||
// Generate Received header.
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, msgBuf.Bytes())
|
||||
if err != nil {
|
||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
expReceivedTotal.Add(1)
|
||||
}
|
||||
s.send("250 Mail accepted for delivery")
|
||||
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
// RFC: remove leading periods from DATA.
|
||||
if len(lineBuf) > 0 && lineBuf[0] == '.' {
|
||||
lineBuf = lineBuf[1:]
|
||||
}
|
||||
msgBuf.Write(lineBuf)
|
||||
if msgBuf.Len() > s.config.MaxMessageBytes {
|
||||
s.send("552 Maximum message size exceeded")
|
||||
s.logger.Warn().Msgf("Max message size exceeded while in DATA")
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
s.logger.Warn().Msgf("Error: %v while reading", err)
|
||||
s.enterState(QUIT)
|
||||
return
|
||||
}
|
||||
mailData := bytes.NewBuffer(msgBuf)
|
||||
|
||||
// Mail data complete.
|
||||
tstamp := time.Now().Format(timeStampFormat)
|
||||
for _, recip := range s.recipients {
|
||||
if recip.ShouldStore() {
|
||||
// Generate Received header.
|
||||
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
|
||||
tstamp)
|
||||
// Deliver message.
|
||||
_, err := s.manager.Deliver(
|
||||
recip, s.from, s.recipients, prefix, mailData.Bytes())
|
||||
if err != nil {
|
||||
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
|
||||
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
expReceivedTotal.Add(1)
|
||||
}
|
||||
s.send("250 Mail accepted for delivery")
|
||||
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
|
||||
s.reset()
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Session) enterState(state State) {
|
||||
@@ -440,7 +460,7 @@ func (s *Session) send(msg string) {
|
||||
s.sendError = err
|
||||
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.logger.Warn().Msgf("Failed to send: %q", msg)
|
||||
return
|
||||
@@ -450,24 +470,27 @@ func (s *Session) send(msg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// readByteLine reads a line of input, returns byte slice.
|
||||
func (s *Session) readByteLine() ([]byte, error) {
|
||||
// readDataBlock reads message DATA until `.` using the textproto pkg.
|
||||
func (s *Session) readDataBlock() ([]byte, error) {
|
||||
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := s.reader.ReadBytes('\n')
|
||||
if err == nil && s.debug {
|
||||
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n"))
|
||||
b, err := s.text.ReadDotBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.debug {
|
||||
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
// Reads a line of input
|
||||
// readLine reads a line of input respecting deadlines.
|
||||
func (s *Session) readLine() (line string, err error) {
|
||||
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err = s.reader.ReadString('\n')
|
||||
line, err = s.text.ReadLine()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -486,7 +509,7 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
case l < 4:
|
||||
s.logger.Warn().Msgf("Command too short: %q", line)
|
||||
return "", "", false
|
||||
case l == 4:
|
||||
case l == 4 || l == 8:
|
||||
return strings.ToUpper(line), "", true
|
||||
case l == 5:
|
||||
// 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+)`)
|
||||
pm := re.FindAllStringSubmatch(arg, -1)
|
||||
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
|
||||
}
|
||||
for _, m := range pm {
|
||||
|
||||
@@ -3,6 +3,7 @@ package smtp
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"expvar"
|
||||
"net"
|
||||
"sync"
|
||||
@@ -63,6 +64,7 @@ type Server struct {
|
||||
manager message.Manager // Used to deliver messages.
|
||||
listener net.Listener // Incoming network connections.
|
||||
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
// NewServer creates a new Server instance with the specificed config.
|
||||
@@ -72,12 +74,28 @@ func NewServer(
|
||||
manager message.Manager,
|
||||
apolicy *policy.Addressing,
|
||||
) *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{
|
||||
config: smtpConfig,
|
||||
globalShutdown: globalShutdown,
|
||||
manager: manager,
|
||||
addrPolicy: apolicy,
|
||||
wg: new(sync.WaitGroup),
|
||||
tlsConfig: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"address": stringutil.StringAddress,
|
||||
"friendlyTime": FriendlyTime,
|
||||
"reverse": Reverse,
|
||||
"stringsJoin": strings.Join,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"expvar"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -70,7 +71,16 @@ func Initialize(
|
||||
Msg("Web UI content mapped")
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
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
|
||||
if conf.Web.CookieAuthKey == "" {
|
||||
@@ -88,7 +98,7 @@ func Initialize(
|
||||
func Start(ctx context.Context) {
|
||||
server = &http.Server{
|
||||
Addr: rootConfig.Web.Addr,
|
||||
Handler: nil,
|
||||
Handler: Router,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/pkg/config"
|
||||
@@ -40,10 +40,11 @@ func countGenerator(c chan int) {
|
||||
// Store implements DataStore aand is the root of the mail storage
|
||||
// hiearchy. It provides access to Mailbox objects
|
||||
type Store struct {
|
||||
hashLock storage.HashLock
|
||||
path string
|
||||
mailPath string
|
||||
messageCap int
|
||||
hashLock storage.HashLock
|
||||
path string
|
||||
mailPath string
|
||||
messageCap int
|
||||
bufReaderPool sync.Pool
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -179,41 +189,33 @@ func (fs *Store) PurgeMessages(mailbox string) error {
|
||||
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
|
||||
// continues to return true.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
// Loop over level 1 directories
|
||||
for _, inf1 := range infos1 {
|
||||
if inf1.IsDir() {
|
||||
l1 := inf1.Name()
|
||||
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
|
||||
for _, name1 := range names1 {
|
||||
names2, err := readDirNames(fs.mailPath, name1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
for _, name2 := range names2 {
|
||||
names3, err := readDirNames(fs.mailPath, name1, name2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
for _, inf2 := range infos2 {
|
||||
if inf2.IsDir() {
|
||||
l2 := inf2.Name()
|
||||
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over mailboxes
|
||||
for _, inf3 := range infos3 {
|
||||
if inf3.IsDir() {
|
||||
mb := fs.mboxFromHash(inf3.Name())
|
||||
mb.RLock()
|
||||
msgs, err := mb.getMessages()
|
||||
mb.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f(msgs) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loop over mailboxes
|
||||
for _, name3 := range names3 {
|
||||
mb := fs.mboxFromHash(name3)
|
||||
mb.RLock()
|
||||
msgs, err := mb.getMessages()
|
||||
mb.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f(msgs) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +255,18 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
|
||||
}
|
||||
}
|
||||
|
||||
// getPooledReader pulls a buffered reader from the fs.bufReaderPool.
|
||||
func (fs *Store) getPooledReader(r io.Reader) *bufio.Reader {
|
||||
br := fs.bufReaderPool.Get().(*bufio.Reader)
|
||||
br.Reset(r)
|
||||
return br
|
||||
}
|
||||
|
||||
// putPooledReader returns a buffered reader to the fs.bufReaderPool.
|
||||
func (fs *Store) putPooledReader(br *bufio.Reader) {
|
||||
fs.bufReaderPool.Put(br)
|
||||
}
|
||||
|
||||
// generatePrefix converts a Time object into the ISO style format we use
|
||||
// as a prefix for message files. Note: It is used directly by unit
|
||||
// tests.
|
||||
@@ -261,7 +275,16 @@ func generatePrefix(date time.Time) 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 {
|
||||
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
|
||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
||||
br := mb.store.getPooledReader(file)
|
||||
defer mb.store.putPooledReader(br)
|
||||
dec := gob.NewDecoder(br)
|
||||
name := ""
|
||||
if err = dec.Decode(&name); err != nil {
|
||||
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.
|
||||
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) {
|
||||
var ok bool
|
||||
m, ok = mb.messages[id]
|
||||
|
||||
@@ -19,13 +19,28 @@ func HashMailboxName(mailbox string) string {
|
||||
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 {
|
||||
s := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
if a != nil {
|
||||
s[i] = a.String()
|
||||
}
|
||||
s[i] = StringAddress(a)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -17,10 +17,14 @@ func TestHashMailboxName(t *testing.T) {
|
||||
|
||||
func TestStringAddressList(t *testing.T) {
|
||||
input := []*mail.Address{
|
||||
{Name: "Fred B. Fish", Address: "fred@fish.org"},
|
||||
{Name: "Fred ß. Fish", Address: "fred@fish.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)
|
||||
if 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{}},
|
||||
{"content", testContent, config.Storage{}},
|
||||
{"delivery order", testDeliveryOrder, config.Storage{}},
|
||||
{"latest", testLatest, config.Storage{}},
|
||||
{"naming", testNaming, config.Storage{}},
|
||||
{"size", testSize, 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.
|
||||
func testNaming(t *testing.T, store storage.Store) {
|
||||
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)
|
||||
}
|
||||
|
||||
// testSize verifies message contnet size metadata values.
|
||||
// testSize verifies message content size metadata values.
|
||||
func testSize(t *testing.T, store storage.Store) {
|
||||
mailbox := "fred"
|
||||
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
|
||||
address and make it available to view without a password.</p>
|
||||
|
||||
<p>To view email for a particular address, enter the username portion
|
||||
<p>To view messages for a particular address, enter the username portion
|
||||
of the address into the box on the upper right and click <em>View</em>.</p>
|
||||
|
||||
<p>This message can be customized by editing greeting.html. Change the
|
||||
configuration option <code>greeting.file</code> if you'd like to move it
|
||||
outside of the Inbucket installation directory.</p>
|
||||
<p>This message can be customized by editing <code>ui/greeting.html</code>.
|
||||
Set the <code>INBUCKET_WEB_GREETINGFILE</code> environment variable if you'd
|
||||
like to move the file outside of the Inbucket installation directory.</p>
|
||||
|
||||
@@ -51,12 +51,12 @@
|
||||
<div class="panel-body">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>From:</dt>
|
||||
<dd>{{.message.From}}</dd>
|
||||
<dd>{{.message.From | address}}</dd>
|
||||
<dt>To:</dt>
|
||||
<dd>
|
||||
{{range $i, $addr := .message.To}}
|
||||
{{- if $i}},{{end}}
|
||||
{{$addr -}}
|
||||
{{$addr | address -}}
|
||||
{{end}}
|
||||
</dd>
|
||||
<dt>Date:</dt>
|
||||
|
||||
Reference in New Issue
Block a user