1
0
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:
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
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

View File

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

View File

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

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
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]
![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
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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{}},
{"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
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
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>

View File

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