mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f58e51d921 | ||
|
|
5f5a7eecd3 | ||
|
|
1ff8ffe9bd | ||
|
|
b4abdb6675 | ||
|
|
ffa756d895 | ||
|
|
d5aea4d635 | ||
|
|
3c19e0820b | ||
|
|
3b9af85924 | ||
|
|
26c38b1148 | ||
|
|
3062b70ea0 | ||
|
|
01d51302c4 | ||
|
|
dedd0eacff | ||
|
|
6431b71cfe | ||
|
|
25815767a7 | ||
|
|
06165cb3d3 | ||
|
|
ac21675bd7 | ||
|
|
f62eaa31b9 | ||
|
|
fcc0848bc0 | ||
|
|
dec67622ba | ||
|
|
11033a5359 | ||
|
|
3a4fd3f093 | ||
|
|
cc47895d71 | ||
|
|
76a77beca9 | ||
|
|
81eba8f51a | ||
|
|
c750dcff81 | ||
|
|
de75b778c0 | ||
|
|
0e72b414c4 | ||
|
|
52de1b2bfe | ||
|
|
b28e1d86d8 | ||
|
|
f4fadd7e44 | ||
|
|
28b40eb94d | ||
|
|
0f67e51e56 | ||
|
|
9d68e2c0a5 | ||
|
|
5bca2ae738 | ||
|
|
10cce5c751 | ||
|
|
8040b07c28 | ||
|
|
4e8c287608 | ||
|
|
6f57c51934 | ||
|
|
a457b65603 | ||
|
|
890d8e0202 | ||
|
|
9f6dee640e | ||
|
|
095796c8a1 | ||
|
|
db358fea8c | ||
|
|
86554a63b8 | ||
|
|
1efe2ba48f | ||
|
|
f597687aa3 | ||
|
|
6368e3a83b | ||
|
|
ef17ad9074 | ||
|
|
7908e41212 | ||
|
|
a9b174bcb6 | ||
|
|
dc0b9b325e | ||
|
|
0a967f0f21 | ||
|
|
304a2260e8 | ||
|
|
9fc9a333a6 | ||
|
|
3e8b914f89 | ||
|
|
5e94f7b750 | ||
|
|
64e75face8 | ||
|
|
be4675b374 | ||
|
|
6722811425 | ||
|
|
56cff6296a | ||
|
|
a1e35009e0 | ||
|
|
cc0428ab9b | ||
|
|
68e35b5eca | ||
|
|
5147865e55 | ||
|
|
a3727ee436 | ||
|
|
9e49480482 | ||
|
|
958f5a44d9 | ||
|
|
9b1d28fc7d | ||
|
|
e6f95c9367 | ||
|
|
de5b9a824b | ||
|
|
9ac3c90036 | ||
|
|
85e3a77fe5 | ||
|
|
32631daeae | ||
|
|
62b77dfe5e | ||
|
|
fa28fa57f8 | ||
|
|
00e4d3791c | ||
|
|
cf7bdee925 | ||
|
|
83b71334c2 | ||
|
|
aa0edff398 | ||
|
|
f09a4558a9 | ||
|
|
1137912e1d | ||
|
|
e14e97919f | ||
|
|
9ae428ca44 | ||
|
|
c346372c85 | ||
|
|
63a76696bf | ||
|
|
86365a047c | ||
|
|
e5aad9f5d0 | ||
|
|
e32e6d00d6 | ||
|
|
b3db619db9 | ||
|
|
6ca2c27747 | ||
|
|
88ccb19360 | ||
|
|
a222b7c428 | ||
|
|
0e02061c4a | ||
|
|
c8fd56ca90 | ||
|
|
d8255382da | ||
|
|
61e9b91637 | ||
|
|
fa6b0a3227 | ||
|
|
61c6e7c2e9 | ||
|
|
dcc0f36f48 | ||
|
|
c1e7de5e14 | ||
|
|
493efb04cd | ||
|
|
ff481c56c6 | ||
|
|
2f5d80a521 | ||
|
|
364e7a0b80 | ||
|
|
26a9903492 | ||
|
|
264fa9e11d | ||
|
|
1906a147f0 | ||
|
|
145e71dc43 | ||
|
|
017a097588 | ||
|
|
01ea89e7e2 | ||
|
|
8f14ba8359 | ||
|
|
8d36aa9750 | ||
|
|
02eee0a608 | ||
|
|
124f830478 | ||
|
|
1856deae46 | ||
|
|
a939605d4a | ||
|
|
f84b36039e | ||
|
|
5ef3adc88e | ||
|
|
6d2c2c8dad | ||
|
|
ebde99949e | ||
|
|
0afaf5109e | ||
|
|
7adf3741d3 | ||
|
|
9821095977 | ||
|
|
221a65cbe6 | ||
|
|
c421e4e0eb | ||
|
|
0068937d58 | ||
|
|
268f950e01 | ||
|
|
1742bf9a34 | ||
|
|
470ef9b496 | ||
|
|
f16debebbf | ||
|
|
ff460309e5 | ||
|
|
d13ebe9149 | ||
|
|
6fd9f1f98c | ||
|
|
3481a89533 | ||
|
|
e99baf766b | ||
|
|
629bb65cec | ||
|
|
42b3ba35cb | ||
|
|
c2779ff054 | ||
|
|
22eb793f61 | ||
|
|
d566da0d86 | ||
|
|
c172ea4dd7 | ||
|
|
511e014a90 | ||
|
|
86861eb747 | ||
|
|
2092949dbc | ||
|
|
798b320769 | ||
|
|
fd59aad4f0 | ||
|
|
ee5f75631a | ||
|
|
8e66be63f5 | ||
|
|
28adcf0437 | ||
|
|
4b4121bb3a | ||
|
|
3a7be7d89c | ||
|
|
e4d12e60aa | ||
|
|
982ad857e8 | ||
|
|
075aa0dd38 | ||
|
|
3f654e48be | ||
|
|
bbfdd4216f | ||
|
|
5e15300d02 | ||
|
|
5daa40b081 | ||
|
|
3eb2b5ce19 | ||
|
|
f36e21a65c | ||
|
|
5da5d3e509 | ||
|
|
7b8161042c | ||
|
|
b9535c126c | ||
|
|
8e084b5697 | ||
|
|
0b32af5495 | ||
|
|
f996fa2ae7 | ||
|
|
9fafbf73d0 | ||
|
|
a2606a14f6 | ||
|
|
30fe43dcc7 | ||
|
|
bcc36ee965 | ||
|
|
44f6407de8 | ||
|
|
e6b7e335cb | ||
|
|
83f9c6aa49 | ||
|
|
ef5a10457e | ||
|
|
e72c5c4b92 | ||
|
|
0f5ba4a7a9 | ||
|
|
c263129711 | ||
|
|
b107cb8787 | ||
|
|
44ff0be01e | ||
|
|
3b0d17867e | ||
|
|
4d8aa340ff | ||
|
|
5760d72bcd | ||
|
|
8e6745b8b7 | ||
|
|
517c68a6b7 | ||
|
|
4144e2b6f0 | ||
|
|
5623ac1e8e | ||
|
|
da28a8ee55 | ||
|
|
f48704b6a6 | ||
|
|
7b8b872ef0 | ||
|
|
028ac2994b | ||
|
|
834efefe46 | ||
|
|
affcc01d19 | ||
|
|
eadc61605a | ||
|
|
9ca1711252 | ||
|
|
bb398498d4 | ||
|
|
cb487c3c7b | ||
|
|
9ebdb06a7a | ||
|
|
8f5ac7ba5b | ||
|
|
88ae99abb0 | ||
|
|
63084c67b9 | ||
|
|
428dc6a286 | ||
|
|
9791039aea | ||
|
|
dcb6a6f845 | ||
|
|
7433e9ac36 | ||
|
|
c34549e783 | ||
|
|
13868d85d4 | ||
|
|
8f10e18fef | ||
|
|
b105bbf87f | ||
|
|
ad85a1db93 | ||
|
|
d418f4ba29 | ||
|
|
d98e6a2b58 | ||
|
|
8b7fbfda6a | ||
|
|
46fa714cc7 | ||
|
|
414ed44882 | ||
|
|
2e1c937d23 | ||
|
|
df11575b3a |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.dockerignore
|
||||
.goxc.json
|
||||
.goxc.local.json
|
||||
.travis.yml
|
||||
inbucket
|
||||
inbucket.exe
|
||||
swaks-tests
|
||||
target
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -23,10 +23,11 @@ _testmain.go
|
||||
|
||||
# vim swp files
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# our binary
|
||||
# our binaries
|
||||
/inbucket
|
||||
/inbucket.exe
|
||||
|
||||
# local goxc config
|
||||
.goxc.local.json
|
||||
/dist/**
|
||||
/cmd/client/client
|
||||
/cmd/client/client.exe
|
||||
|
||||
60
.goreleaser.yml
Normal file
60
.goreleaser.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
project_name: inbucket
|
||||
release:
|
||||
github:
|
||||
owner: jhillyerd
|
||||
name: inbucket
|
||||
name_template: '{{.Tag}}'
|
||||
brew:
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
install: bin.install ""
|
||||
builds:
|
||||
- binary: inbucket
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
goarm:
|
||||
- "6"
|
||||
main: .
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
- binary: client
|
||||
goos:
|
||||
- darwin
|
||||
- freebsd
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
goarm:
|
||||
- "6"
|
||||
main: ./cmd/client
|
||||
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||
archive:
|
||||
format: tar.gz
|
||||
wrap_in_directory: true
|
||||
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
|
||||
.Arm }}{{ end }}'
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- README*
|
||||
- CHANGELOG*
|
||||
- inbucket.bat
|
||||
- etc/**/*
|
||||
- themes/**/*
|
||||
fpm:
|
||||
bindir: /usr/local/bin
|
||||
snapshot:
|
||||
name_template: SNAPSHOT-{{ .Commit }}
|
||||
checksum:
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
|
||||
dist: dist
|
||||
sign:
|
||||
artifacts: none
|
||||
13
.goxc.json
13
.goxc.json
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"TasksExclude": [
|
||||
"pkg-build"
|
||||
],
|
||||
"Arch": "amd64",
|
||||
"Os": "darwin freebsd linux windows",
|
||||
"Resources": {
|
||||
"Include": "README*,LICENSE*,inbucket.bat,etc,themes"
|
||||
},
|
||||
"PackageVersion": "1.0",
|
||||
"PrereleaseInfo": "rc2",
|
||||
"FormatVersion": "0.8"
|
||||
}
|
||||
20
.travis.yml
20
.travis.yml
@@ -1,9 +1,19 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
env:
|
||||
- DEPLOY_WITH_MAJOR="1.9"
|
||||
|
||||
before_script:
|
||||
- go get github.com/golang/lint/golint
|
||||
|
||||
go:
|
||||
- 1.1
|
||||
- tip
|
||||
- 1.9.x
|
||||
- "1.10"
|
||||
|
||||
install:
|
||||
- go get -v ./...
|
||||
- go get github.com/stretchr/testify
|
||||
deploy:
|
||||
provider: script
|
||||
script: etc/travis-deploy.sh
|
||||
on:
|
||||
tags: true
|
||||
branch: master
|
||||
|
||||
125
CHANGELOG.md
Normal file
125
CHANGELOG.md
Normal file
@@ -0,0 +1,125 @@
|
||||
Change Log
|
||||
==========
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [v1.3.0] - 2018-02-28
|
||||
|
||||
### Added
|
||||
- Button to purge mailbox contents from the UI.
|
||||
- Simple HTML/CSS sanitization; `Safe HTML` and `Plain Text` UI tabs.
|
||||
|
||||
### Changed
|
||||
- Reverse message display sort order in the UI; now newest first.
|
||||
|
||||
## [v1.2.0] - 2017-12-27
|
||||
|
||||
### Changed
|
||||
- No significant code changes from rc2
|
||||
|
||||
### Added
|
||||
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
|
||||
provides a more natural API
|
||||
- Powerful command line REST
|
||||
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
|
||||
- Allow use of `latest` as a message ID in REST calls
|
||||
|
||||
### Changed
|
||||
- `rest/client.NewV1` renamed to `New`
|
||||
- `rest/client` package now embeds the shared `rest/model` structs into its own
|
||||
types
|
||||
- Fixed panic when `monitor.history` set to 0
|
||||
|
||||
## [v1.2.0-rc1] - 2017-01-29
|
||||
|
||||
### Added
|
||||
- Storage of `To:` header in messages (likely breaks existing datastores)
|
||||
- Attachment list to [GET message
|
||||
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
|
||||
- [Go client for REST
|
||||
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
|
||||
- Monitor feature: lists messages as they arrive, regardless of their
|
||||
destination mailbox
|
||||
- Make `@inbucket` mailbox prompt configurable
|
||||
- Warnings and errors from MIME parser are displayed with message
|
||||
|
||||
### Fixed
|
||||
- No longer run out of file handles when dealing with a large number of
|
||||
recipients for a single message.
|
||||
- Empty intermediate directories are now removed when a mailbox is deleted,
|
||||
leaving less junk on your filesystem.
|
||||
|
||||
### Changed
|
||||
- Build now requires Go 1.7 or later
|
||||
- Removed legacy `integral` theme, as most new features only in `bootstrap`
|
||||
- Removed old RESTful APIs, must use `/api/v1` base URI now
|
||||
- Allow increased local-part length of 128 chars for Mailgun
|
||||
- RedHat and Ubuntu now use systemd instead of legacy init systems
|
||||
|
||||
## [v1.1.0] - 2016-09-03
|
||||
|
||||
### Added
|
||||
- Homebrew inbucket.conf and formula (see README)
|
||||
|
||||
### Fixed
|
||||
- Log and continue when unable to delete oldest message during cap enforcement
|
||||
|
||||
## [v1.1.0-rc2] - 2016-03-06
|
||||
|
||||
### Added
|
||||
- Message Cap to status page
|
||||
- Search-while-you-type message list filter
|
||||
|
||||
### Fixed
|
||||
- Shutdown hang in retention scanner
|
||||
- Display empty subject as `(No Subject)`
|
||||
|
||||
## [v1.1.0-rc1] - 2016-03-04
|
||||
|
||||
### Added
|
||||
- Inbucket now builds with Go 1.5 or 1.6
|
||||
- Project can build & run inside a Docker container
|
||||
- Add new default theme based on Bootstrap
|
||||
- Your recently accessed mailboxes are listed in the GUI
|
||||
- HTML-only messages now get down-converted to plain text automatically
|
||||
- This change log
|
||||
|
||||
### Changed
|
||||
- RESTful API moved to `/api/v1` base URI
|
||||
- More graceful shutdown on Ctrl-C or when errors encountered
|
||||
|
||||
## [v1.0] - 2014-04-14
|
||||
|
||||
### Added
|
||||
- Add new configuration option `mailbox.message.cap` to prevent individual
|
||||
mailboxes from growing too large.
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
|
||||
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
||||
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
||||
|
||||
|
||||
## Release Checklist
|
||||
|
||||
1. Create release branch: `git flow release start 1.x.0`
|
||||
2. Update CHANGELOG.md:
|
||||
- Ensure *Unreleased* section is up to date
|
||||
- Rename *Unreleased* section to release name and date.
|
||||
- Add new GitHub `/compare` link
|
||||
3. Run tests
|
||||
4. Test cross-compile: `goreleaser --snapshot`
|
||||
5. Commit changes and merge release: `git flow release finish`
|
||||
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
|
||||
complete
|
||||
7. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
|
||||
See http://keepachangelog.com/ for additional instructions on how to update this file.
|
||||
44
CONTRIBUTING.md
Normal file
44
CONTRIBUTING.md
Normal file
@@ -0,0 +1,44 @@
|
||||
How to Contribute
|
||||
=================
|
||||
|
||||
Inbucket encourages third-party patches. It's valuable to know how other
|
||||
developers are using the product.
|
||||
|
||||
**tl;dr:** File pull requests against the `develop` branch, not `master`!
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
If you anticipate your issue requiring a large patch, please first submit a
|
||||
GitHub issue describing the problem or feature. You are also encouraged to
|
||||
outline the process you would like to use to resolve the issue. I will attempt
|
||||
to provide validation and/or guidance on your suggested approach.
|
||||
|
||||
|
||||
## Making Changes
|
||||
|
||||
Inbucket uses [git-flow] with default options. If you have git-flow installed,
|
||||
you can run `git flow feature start <topic branch name>`.
|
||||
|
||||
Without git-flow, create a topic branch from where you want to base your work:
|
||||
- This is usually the `develop` branch, example command:
|
||||
`git checkout origin/develop -b <topic branch name>`
|
||||
- Only target the `master` branch if the issue is already resolved in
|
||||
`develop`.
|
||||
|
||||
Once you are on your topic branch:
|
||||
|
||||
1. Make commits of logical units.
|
||||
2. Add unit tests to exercise your changes.
|
||||
3. Run the updated code through `go fmt` and `go vet`.
|
||||
4. Ensure the code builds and tests with the following commands:
|
||||
- `go clean ./...`
|
||||
- `go build ./...`
|
||||
- `go test ./...`
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
Thank you for contributing to Inbucket!
|
||||
|
||||
[git-flow]: https://github.com/nvie/gitflow
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# Docker build file for Inbucket, see https://www.docker.io/
|
||||
# Inbucket website: http://www.inbucket.org/
|
||||
|
||||
FROM golang:1.9-alpine
|
||||
MAINTAINER James Hillyerd, @jameshillyerd
|
||||
|
||||
# Configuration (WORKDIR doesn't support env vars)
|
||||
ENV INBUCKET_SRC $GOPATH/src/github.com/jhillyerd/inbucket
|
||||
ENV INBUCKET_HOME /opt/inbucket
|
||||
WORKDIR $INBUCKET_HOME
|
||||
ENTRYPOINT ["/con/context/start-inbucket.sh"]
|
||||
CMD ["/con/configuration/inbucket.conf"]
|
||||
|
||||
# Ports: SMTP, HTTP, POP3
|
||||
EXPOSE 10025 10080 10110
|
||||
|
||||
# Persistent Volumes, following convention at:
|
||||
# https://github.com/docker/docker/issues/9277
|
||||
# NOTE /con/context is also used, not exposed by default
|
||||
VOLUME /con/configuration
|
||||
VOLUME /con/data
|
||||
|
||||
# Build Inbucket
|
||||
COPY . $INBUCKET_SRC/
|
||||
RUN "$INBUCKET_SRC/etc/docker/install.sh"
|
||||
35
Makefile
Normal file
35
Makefile
Normal file
@@ -0,0 +1,35 @@
|
||||
PKG := inbucket
|
||||
SHELL := /bin/sh
|
||||
|
||||
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
|
||||
PKGS := $$(go list ./... | grep -v /vendor/)
|
||||
|
||||
.PHONY: all build clean fmt install lint simplify test
|
||||
|
||||
all: test lint build
|
||||
|
||||
clean:
|
||||
go clean
|
||||
|
||||
deps:
|
||||
go get -t ./...
|
||||
|
||||
build: clean deps
|
||||
go build
|
||||
|
||||
install: build
|
||||
go install
|
||||
|
||||
test: clean deps
|
||||
go test -race ./...
|
||||
|
||||
fmt:
|
||||
@gofmt -l -w $(SRC)
|
||||
|
||||
simplify:
|
||||
@gofmt -s -l -w $(SRC)
|
||||
|
||||
lint:
|
||||
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
|
||||
@golint -set_exit_status $${PKGS}
|
||||
@go vet $${PKGS}
|
||||
59
README.md
59
README.md
@@ -1,35 +1,33 @@
|
||||
Inbucket [](https://travis-ci.org/jhillyerd/inbucket)
|
||||
========
|
||||
Inbucket
|
||||
=============================================================================
|
||||
[][Build Status]
|
||||
|
||||
Inbucket is an email testing service; it will accept messages for any email
|
||||
address and make them available to view via a web interface.
|
||||
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).
|
||||
|
||||
It allows web developers, software engineers and system administrators to
|
||||
quickly see the emailed output of ther applications. No per-account setup is
|
||||
required! Mailboxes are created on the fly as mail is received for them, and
|
||||
no password is required to browse the content of the mailboxes.
|
||||
Read more at the [Inbucket Website]
|
||||
|
||||
Inbucket has a built-in SMTP server and stores incoming mail as flat files on
|
||||
disk - no external SMTP or database daemons required.
|
||||

|
||||
|
||||
There is also an embedded POP3 server, which allows message rendering to be
|
||||
checked in multiple email programs or to verify message delivery as part of
|
||||
an integration test suite.
|
||||
## Development Status
|
||||
|
||||
Read more at the [Inbucket website](http://jhillyerd.github.io/inbucket/).
|
||||
Inbucket is currently production quality: it is being used for real work.
|
||||
|
||||
Development Status
|
||||
------------------
|
||||
Please see the [Change Log] and [Issues List] for more details. If you'd like
|
||||
to contribute code to the project check out [CONTRIBUTING.md].
|
||||
|
||||
Inbucket is currently release-candidate quality: it is being used for real work.
|
||||
|
||||
Please check the [issues list](https://github.com/jhillyerd/inbucket/issues?state=open)
|
||||
for more details.
|
||||
## Homebrew Tap
|
||||
|
||||
Installation from Source
|
||||
------------------------
|
||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||
see the `README.md` there for installation instructions.
|
||||
|
||||
You will need a functioning [Go installation][1] for this to work.
|
||||
|
||||
## Building from Source
|
||||
|
||||
You will need a functioning [Go installation][Google Go] for this to work.
|
||||
|
||||
Grab the Inbucket source code and compile the daemon:
|
||||
|
||||
@@ -43,14 +41,23 @@ Unix and OS X machines as is. Launch the daemon:
|
||||
By default the SMTP server will be listening on localhost port 2500 and
|
||||
the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||
|
||||
There are RedHat EL6 init, logrotate and httpd proxy configs provided.
|
||||
The Inbucket website has a more complete guide to
|
||||
[installing from source][From Source]
|
||||
|
||||
About
|
||||
-----
|
||||
|
||||
Inbucket is written in [Google Go][1].
|
||||
## About
|
||||
|
||||
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
|
||||
|
||||
[1]: http://golang.org/
|
||||
[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
|
||||
[From Source]: http://www.inbucket.org/installation/from-source.html
|
||||
[Google Go]: http://golang.org/
|
||||
[Homebrew]: http://brew.sh/
|
||||
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
|
||||
[Inbucket Website]: http://www.inbucket.org/
|
||||
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
|
||||
|
||||
54
cmd/client/list.go
Normal file
54
cmd/client/list.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/rest/client"
|
||||
)
|
||||
|
||||
type listCmd struct {
|
||||
mailbox string
|
||||
}
|
||||
|
||||
func (*listCmd) Name() string {
|
||||
return "list"
|
||||
}
|
||||
|
||||
func (*listCmd) Synopsis() string {
|
||||
return "list contents of mailbox"
|
||||
}
|
||||
|
||||
func (*listCmd) Usage() string {
|
||||
return `list <mailbox>:
|
||||
list message IDs in mailbox
|
||||
`
|
||||
}
|
||||
|
||||
func (l *listCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (l *listCmd) Execute(
|
||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
mailbox := f.Arg(0)
|
||||
if mailbox == "" {
|
||||
return usage("mailbox required")
|
||||
}
|
||||
// Setup rest client
|
||||
c, err := client.New(baseURL())
|
||||
if err != nil {
|
||||
return fatal("Couldn't build client", err)
|
||||
}
|
||||
// Get list
|
||||
headers, err := c.ListMailbox(mailbox)
|
||||
if err != nil {
|
||||
return fatal("REST call failed", err)
|
||||
}
|
||||
for _, h := range headers {
|
||||
fmt.Println(h.ID)
|
||||
}
|
||||
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
79
cmd/client/main.go
Normal file
79
cmd/client/main.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package main implements a command line client for the Inbucket REST API
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
)
|
||||
|
||||
var host = flag.String("host", "localhost", "host/IP of Inbucket server")
|
||||
var port = flag.Uint("port", 9000, "HTTP port of Inbucket server")
|
||||
|
||||
// Allow subcommands to accept regular expressions as flags
|
||||
type regexFlag struct {
|
||||
*regexp.Regexp
|
||||
}
|
||||
|
||||
func (r *regexFlag) Defined() bool {
|
||||
return r.Regexp != nil
|
||||
}
|
||||
|
||||
func (r *regexFlag) Set(pattern string) error {
|
||||
if pattern == "" {
|
||||
r.Regexp = nil
|
||||
return nil
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Regexp = re
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *regexFlag) String() string {
|
||||
if r.Regexp == nil {
|
||||
return ""
|
||||
}
|
||||
return r.Regexp.String()
|
||||
}
|
||||
|
||||
// regexFlag must implement flag.Value
|
||||
var _ flag.Value = ®exFlag{}
|
||||
|
||||
func main() {
|
||||
// Important top-level flags
|
||||
subcommands.ImportantFlag("host")
|
||||
subcommands.ImportantFlag("port")
|
||||
// Setup standard helpers
|
||||
subcommands.Register(subcommands.HelpCommand(), "")
|
||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||
// Setup my commands
|
||||
subcommands.Register(&listCmd{}, "")
|
||||
subcommands.Register(&matchCmd{}, "")
|
||||
subcommands.Register(&mboxCmd{}, "")
|
||||
// Parse and execute
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
os.Exit(int(subcommands.Execute(ctx)))
|
||||
}
|
||||
|
||||
func baseURL() string {
|
||||
return fmt.Sprintf("http://%s:%v", *host, *port)
|
||||
}
|
||||
|
||||
func fatal(msg string, err error) subcommands.ExitStatus {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
func usage(msg string) subcommands.ExitStatus {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
return subcommands.ExitUsageError
|
||||
}
|
||||
164
cmd/client/match.go
Normal file
164
cmd/client/match.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/rest/client"
|
||||
)
|
||||
|
||||
type matchCmd struct {
|
||||
mailbox string
|
||||
output string
|
||||
outFunc func(headers []*client.MessageHeader) error
|
||||
delete bool
|
||||
// match criteria
|
||||
from regexFlag
|
||||
subject regexFlag
|
||||
to regexFlag
|
||||
maxAge time.Duration
|
||||
}
|
||||
|
||||
func (*matchCmd) Name() string {
|
||||
return "match"
|
||||
}
|
||||
|
||||
func (*matchCmd) Synopsis() string {
|
||||
return "output messages matching criteria"
|
||||
}
|
||||
|
||||
func (*matchCmd) Usage() string {
|
||||
return `match [flags] <mailbox>:
|
||||
output messages matching all specified criteria
|
||||
exit status will be 1 if no matches were found, otherwise 0
|
||||
`
|
||||
}
|
||||
|
||||
func (m *matchCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&m.output, "output", "id", "output format: id, json, or mbox")
|
||||
f.BoolVar(&m.delete, "delete", false, "delete matched messages after output")
|
||||
f.Var(&m.from, "from", "From header matching regexp (address, not name)")
|
||||
f.Var(&m.subject, "subject", "Subject header matching regexp")
|
||||
f.Var(&m.to, "to", "To header matching regexp (must match 1+ to address)")
|
||||
f.DurationVar(
|
||||
&m.maxAge, "maxage", 0,
|
||||
"Matches must have been received in this time frame (ex: \"10s\", \"5m\")")
|
||||
}
|
||||
|
||||
func (m *matchCmd) Execute(
|
||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
mailbox := f.Arg(0)
|
||||
if mailbox == "" {
|
||||
return usage("mailbox required")
|
||||
}
|
||||
// Select output function
|
||||
switch m.output {
|
||||
case "id":
|
||||
m.outFunc = outputID
|
||||
case "json":
|
||||
m.outFunc = outputJSON
|
||||
case "mbox":
|
||||
m.outFunc = outputMbox
|
||||
default:
|
||||
return usage("unknown output type: " + m.output)
|
||||
}
|
||||
// Setup REST client
|
||||
c, err := client.New(baseURL())
|
||||
if err != nil {
|
||||
return fatal("Couldn't build client", err)
|
||||
}
|
||||
// Get list
|
||||
headers, err := c.ListMailbox(mailbox)
|
||||
if err != nil {
|
||||
return fatal("List REST call failed", err)
|
||||
}
|
||||
// Find matches
|
||||
matches := make([]*client.MessageHeader, 0, len(headers))
|
||||
for _, h := range headers {
|
||||
if m.match(h) {
|
||||
matches = append(matches, h)
|
||||
}
|
||||
}
|
||||
// Return error status if no matches
|
||||
if len(matches) == 0 {
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
// Output matches
|
||||
err = m.outFunc(matches)
|
||||
if err != nil {
|
||||
return fatal("Error", err)
|
||||
}
|
||||
if m.delete {
|
||||
// Delete matches
|
||||
for _, h := range matches {
|
||||
err = h.Delete()
|
||||
if err != nil {
|
||||
return fatal("Delete REST call failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// match returns true if header matches all defined criteria
|
||||
func (m *matchCmd) match(header *client.MessageHeader) bool {
|
||||
if m.maxAge > 0 {
|
||||
if time.Since(header.Date) > m.maxAge {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if m.subject.Defined() {
|
||||
if !m.subject.MatchString(header.Subject) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if m.from.Defined() {
|
||||
from := header.From
|
||||
addr, err := mail.ParseAddress(from)
|
||||
if err == nil {
|
||||
// Parsed successfully
|
||||
from = addr.Address
|
||||
}
|
||||
if !m.from.MatchString(from) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if m.to.Defined() {
|
||||
match := false
|
||||
for _, to := range header.To {
|
||||
addr, err := mail.ParseAddress(to)
|
||||
if err == nil {
|
||||
// Parsed successfully
|
||||
to = addr.Address
|
||||
}
|
||||
if m.to.MatchString(to) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func outputID(headers []*client.MessageHeader) error {
|
||||
for _, h := range headers {
|
||||
fmt.Println(h.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputJSON(headers []*client.MessageHeader) error {
|
||||
jsonEncoder := json.NewEncoder(os.Stdout)
|
||||
jsonEncoder.SetEscapeHTML(false)
|
||||
jsonEncoder.SetIndent("", " ")
|
||||
return jsonEncoder.Encode(headers)
|
||||
}
|
||||
82
cmd/client/mbox.go
Normal file
82
cmd/client/mbox.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/jhillyerd/inbucket/rest/client"
|
||||
)
|
||||
|
||||
type mboxCmd struct {
|
||||
mailbox string
|
||||
delete bool
|
||||
}
|
||||
|
||||
func (*mboxCmd) Name() string {
|
||||
return "mbox"
|
||||
}
|
||||
|
||||
func (*mboxCmd) Synopsis() string {
|
||||
return "output mailbox in mbox format"
|
||||
}
|
||||
|
||||
func (*mboxCmd) Usage() string {
|
||||
return `mbox [flags] <mailbox>:
|
||||
output mailbox in mbox format
|
||||
`
|
||||
}
|
||||
|
||||
func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.BoolVar(&m.delete, "delete", false, "delete messages after output")
|
||||
}
|
||||
|
||||
func (m *mboxCmd) Execute(
|
||||
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
mailbox := f.Arg(0)
|
||||
if mailbox == "" {
|
||||
return usage("mailbox required")
|
||||
}
|
||||
// Setup REST client
|
||||
c, err := client.New(baseURL())
|
||||
if err != nil {
|
||||
return fatal("Couldn't build client", err)
|
||||
}
|
||||
// Get list
|
||||
headers, err := c.ListMailbox(mailbox)
|
||||
if err != nil {
|
||||
return fatal("List REST call failed", err)
|
||||
}
|
||||
err = outputMbox(headers)
|
||||
if err != nil {
|
||||
return fatal("Error", err)
|
||||
}
|
||||
if m.delete {
|
||||
// Delete matches
|
||||
for _, h := range headers {
|
||||
err = h.Delete()
|
||||
if err != nil {
|
||||
return fatal("Delete REST call failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// outputMbox renders messages in mbox format
|
||||
// also used by match subcommand
|
||||
func outputMbox(headers []*client.MessageHeader) error {
|
||||
for _, h := range headers {
|
||||
source, err := h.GetSource()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Get source REST failed: %v", err)
|
||||
}
|
||||
fmt.Printf("From %s\n", h.From)
|
||||
// TODO Escape "From " in message bodies with >
|
||||
source.WriteTo(os.Stdout)
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
506
config/config.go
506
config/config.go
@@ -1,19 +1,20 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"fmt"
|
||||
"github.com/robfig/config"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/robfig/config"
|
||||
)
|
||||
|
||||
// SmtpConfig houses the SMTP server configuration - not using pointers
|
||||
// so that I can pass around copies of the object safely.
|
||||
type SmtpConfig struct {
|
||||
Ip4address net.IP
|
||||
Ip4port int
|
||||
// SMTPConfig contains the SMTP server configuration - not using pointers so that we can pass around
|
||||
// copies of the object safely.
|
||||
type SMTPConfig struct {
|
||||
IP4address net.IP
|
||||
IP4port int
|
||||
Domain string
|
||||
DomainNoStore string
|
||||
MaxRecipients int
|
||||
@@ -22,46 +23,66 @@ type SmtpConfig struct {
|
||||
StoreMessages bool
|
||||
}
|
||||
|
||||
type Pop3Config struct {
|
||||
Ip4address net.IP
|
||||
Ip4port int
|
||||
// POP3Config contains the POP3 server configuration
|
||||
type POP3Config struct {
|
||||
IP4address net.IP
|
||||
IP4port int
|
||||
Domain string
|
||||
MaxIdleSeconds int
|
||||
}
|
||||
|
||||
// WebConfig contains the HTTP server configuration
|
||||
type WebConfig struct {
|
||||
Ip4address net.IP
|
||||
Ip4port int
|
||||
TemplateDir string
|
||||
TemplateCache bool
|
||||
PublicDir string
|
||||
GreetingFile string
|
||||
IP4address net.IP
|
||||
IP4port int
|
||||
TemplateDir string
|
||||
TemplateCache bool
|
||||
PublicDir string
|
||||
GreetingFile string
|
||||
MailboxPrompt string
|
||||
CookieAuthKey string
|
||||
MonitorVisible bool
|
||||
MonitorHistory int
|
||||
}
|
||||
|
||||
// DataStoreConfig contains the mail store configuration
|
||||
type DataStoreConfig struct {
|
||||
Path string
|
||||
RetentionMinutes int
|
||||
RetentionSleep int
|
||||
MailboxMsgCap int
|
||||
}
|
||||
|
||||
var (
|
||||
// Global goconfig object
|
||||
Config *config.Config
|
||||
|
||||
// Parsed specific configs
|
||||
smtpConfig *SmtpConfig
|
||||
pop3Config *Pop3Config
|
||||
webConfig *WebConfig
|
||||
dataStoreConfig *DataStoreConfig
|
||||
const (
|
||||
missingErrorFmt = "[%v] missing required option %q"
|
||||
parseErrorFmt = "[%v] option %q error: %v"
|
||||
)
|
||||
|
||||
// GetSmtpConfig returns a copy of the SmtpConfig object
|
||||
func GetSmtpConfig() SmtpConfig {
|
||||
var (
|
||||
// Version of this build, set by main
|
||||
Version = ""
|
||||
|
||||
// BuildDate for this build, set by main
|
||||
BuildDate = ""
|
||||
|
||||
// Config is our global robfig/config object
|
||||
Config *config.Config
|
||||
logLevel string
|
||||
|
||||
// Parsed specific configs
|
||||
smtpConfig = &SMTPConfig{}
|
||||
pop3Config = &POP3Config{}
|
||||
webConfig = &WebConfig{}
|
||||
dataStoreConfig = &DataStoreConfig{}
|
||||
)
|
||||
|
||||
// GetSMTPConfig returns a copy of the SmtpConfig object
|
||||
func GetSMTPConfig() SMTPConfig {
|
||||
return *smtpConfig
|
||||
}
|
||||
|
||||
// GetPop3Config returns a copy of the Pop3Config object
|
||||
func GetPop3Config() Pop3Config {
|
||||
// GetPOP3Config returns a copy of the Pop3Config object
|
||||
func GetPOP3Config() POP3Config {
|
||||
return *pop3Config
|
||||
}
|
||||
|
||||
@@ -75,308 +96,175 @@ func GetDataStoreConfig() DataStoreConfig {
|
||||
return *dataStoreConfig
|
||||
}
|
||||
|
||||
// LoadConfig loads the specified configuration file into inbucket.Config
|
||||
// and performs validations on it.
|
||||
// GetLogLevel returns the configured log level
|
||||
func GetLogLevel() string {
|
||||
return logLevel
|
||||
}
|
||||
|
||||
// LoadConfig loads the specified configuration file into inbucket.Config and performs validations
|
||||
// on it.
|
||||
func LoadConfig(filename string) error {
|
||||
var err error
|
||||
Config, err = config.ReadDefault(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messages := list.New()
|
||||
|
||||
// Validation error messages
|
||||
messages := make([]string, 0)
|
||||
// Validate sections
|
||||
requireSection(messages, "logging")
|
||||
requireSection(messages, "smtp")
|
||||
requireSection(messages, "pop3")
|
||||
requireSection(messages, "web")
|
||||
requireSection(messages, "datastore")
|
||||
if messages.Len() > 0 {
|
||||
for _, s := range []string{"logging", "smtp", "pop3", "web", "datastore"} {
|
||||
if !Config.HasSection(s) {
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Config section [%v] is required", s))
|
||||
}
|
||||
}
|
||||
// Return immediately if config is missing entire sections
|
||||
if len(messages) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
||||
for e := messages.Front(); e != nil; e = e.Next() {
|
||||
fmt.Fprintln(os.Stderr, " -", e.Value.(string))
|
||||
for _, m := range messages {
|
||||
fmt.Fprintln(os.Stderr, " -", m)
|
||||
}
|
||||
return fmt.Errorf("Failed to validate configuration")
|
||||
}
|
||||
|
||||
// Validate options
|
||||
requireOption(messages, "logging", "level")
|
||||
requireOption(messages, "smtp", "ip4.address")
|
||||
requireOption(messages, "smtp", "ip4.port")
|
||||
requireOption(messages, "smtp", "domain")
|
||||
requireOption(messages, "smtp", "max.recipients")
|
||||
requireOption(messages, "smtp", "max.idle.seconds")
|
||||
requireOption(messages, "smtp", "max.message.bytes")
|
||||
requireOption(messages, "smtp", "store.messages")
|
||||
requireOption(messages, "pop3", "ip4.address")
|
||||
requireOption(messages, "pop3", "ip4.port")
|
||||
requireOption(messages, "pop3", "domain")
|
||||
requireOption(messages, "pop3", "max.idle.seconds")
|
||||
requireOption(messages, "web", "ip4.address")
|
||||
requireOption(messages, "web", "ip4.port")
|
||||
requireOption(messages, "web", "template.dir")
|
||||
requireOption(messages, "web", "template.cache")
|
||||
requireOption(messages, "web", "public.dir")
|
||||
requireOption(messages, "datastore", "path")
|
||||
requireOption(messages, "datastore", "retention.minutes")
|
||||
requireOption(messages, "datastore", "retention.sleep.millis")
|
||||
|
||||
// Return error if validations failed
|
||||
if messages.Len() > 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
||||
for e := messages.Front(); e != nil; e = e.Next() {
|
||||
fmt.Fprintln(os.Stderr, " -", e.Value.(string))
|
||||
// Load string config options
|
||||
stringOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *string
|
||||
required bool
|
||||
}{
|
||||
{"logging", "level", &logLevel, true},
|
||||
{"smtp", "domain", &smtpConfig.Domain, true},
|
||||
{"smtp", "domain.nostore", &smtpConfig.DomainNoStore, false},
|
||||
{"pop3", "domain", &pop3Config.Domain, true},
|
||||
{"web", "template.dir", &webConfig.TemplateDir, true},
|
||||
{"web", "public.dir", &webConfig.PublicDir, true},
|
||||
{"web", "greeting.file", &webConfig.GreetingFile, true},
|
||||
{"web", "mailbox.prompt", &webConfig.MailboxPrompt, false},
|
||||
{"web", "cookie.auth.key", &webConfig.CookieAuthKey, false},
|
||||
{"datastore", "path", &dataStoreConfig.Path, true},
|
||||
}
|
||||
for _, opt := range stringOptions {
|
||||
str, err := Config.String(opt.section, opt.name)
|
||||
if Config.HasOption(opt.section, opt.name) && err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("Failed to validate configuration")
|
||||
if str == "" && opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
*opt.target = str
|
||||
}
|
||||
|
||||
if err = parseSmtpConfig(); err != nil {
|
||||
return err
|
||||
// Load boolean config options
|
||||
boolOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *bool
|
||||
required bool
|
||||
}{
|
||||
{"smtp", "store.messages", &smtpConfig.StoreMessages, true},
|
||||
{"web", "template.cache", &webConfig.TemplateCache, true},
|
||||
{"web", "monitor.visible", &webConfig.MonitorVisible, true},
|
||||
}
|
||||
|
||||
if err = parsePop3Config(); err != nil {
|
||||
return err
|
||||
for _, opt := range boolOptions {
|
||||
if Config.HasOption(opt.section, opt.name) {
|
||||
flag, err := Config.Bool(opt.section, opt.name)
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
}
|
||||
*opt.target = flag
|
||||
} else {
|
||||
if opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = parseWebConfig(); err != nil {
|
||||
return err
|
||||
// Load integer config options
|
||||
intOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *int
|
||||
required bool
|
||||
}{
|
||||
{"smtp", "ip4.port", &smtpConfig.IP4port, true},
|
||||
{"smtp", "max.recipients", &smtpConfig.MaxRecipients, true},
|
||||
{"smtp", "max.idle.seconds", &smtpConfig.MaxIdleSeconds, true},
|
||||
{"smtp", "max.message.bytes", &smtpConfig.MaxMessageBytes, true},
|
||||
{"pop3", "ip4.port", &pop3Config.IP4port, true},
|
||||
{"pop3", "max.idle.seconds", &pop3Config.MaxIdleSeconds, true},
|
||||
{"web", "ip4.port", &webConfig.IP4port, true},
|
||||
{"web", "monitor.history", &webConfig.MonitorHistory, true},
|
||||
{"datastore", "retention.minutes", &dataStoreConfig.RetentionMinutes, true},
|
||||
{"datastore", "retention.sleep.millis", &dataStoreConfig.RetentionSleep, true},
|
||||
{"datastore", "mailbox.message.cap", &dataStoreConfig.MailboxMsgCap, true},
|
||||
}
|
||||
|
||||
if err = parseDataStoreConfig(); err != nil {
|
||||
return err
|
||||
for _, opt := range intOptions {
|
||||
if Config.HasOption(opt.section, opt.name) {
|
||||
num, err := Config.Int(opt.section, opt.name)
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
}
|
||||
*opt.target = num
|
||||
} else {
|
||||
if opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLoggingConfig trying to catch config errors early
|
||||
func parseLoggingConfig() error {
|
||||
section := "logging"
|
||||
|
||||
option := "level"
|
||||
str, err := Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
// Load IP address config options
|
||||
ipOptions := []struct {
|
||||
section string
|
||||
name string
|
||||
target *net.IP
|
||||
required bool
|
||||
}{
|
||||
{"smtp", "ip4.address", &smtpConfig.IP4address, true},
|
||||
{"pop3", "ip4.address", &pop3Config.IP4address, true},
|
||||
{"web", "ip4.address", &webConfig.IP4address, true},
|
||||
}
|
||||
switch strings.ToUpper(str) {
|
||||
for _, opt := range ipOptions {
|
||||
if Config.HasOption(opt.section, opt.name) {
|
||||
str, err := Config.String(opt.section, opt.name)
|
||||
if err != nil {
|
||||
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
|
||||
continue
|
||||
}
|
||||
addr := net.ParseIP(str)
|
||||
if addr == nil {
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Failed to parse IP [%v]%v: %q", opt.section, opt.name, str))
|
||||
continue
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Failed to parse IP [%v]%v: %q not IPv4!",
|
||||
opt.section, opt.name, str))
|
||||
}
|
||||
*opt.target = addr
|
||||
} else {
|
||||
if opt.required {
|
||||
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate log level
|
||||
switch strings.ToUpper(logLevel) {
|
||||
case "":
|
||||
// Missing was already reported
|
||||
case "TRACE", "INFO", "WARN", "ERROR":
|
||||
default:
|
||||
return fmt.Errorf("Invalid value provided for [%v]%v: '%v'", section, option, str)
|
||||
messages = append(messages,
|
||||
fmt.Sprintf("Invalid value provided for [logging]level: %q", logLevel))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSmtpConfig trying to catch config errors early
|
||||
func parseSmtpConfig() error {
|
||||
smtpConfig = new(SmtpConfig)
|
||||
section := "smtp"
|
||||
|
||||
// Parse IP4 address only, error on IP6.
|
||||
option := "ip4.address"
|
||||
str, err := Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
addr := net.ParseIP(str)
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err)
|
||||
}
|
||||
smtpConfig.Ip4address = addr
|
||||
|
||||
option = "ip4.port"
|
||||
smtpConfig.Ip4port, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
option = "domain"
|
||||
str, err = Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
smtpConfig.Domain = str
|
||||
|
||||
option = "domain.nostore"
|
||||
if Config.HasOption(section, option) {
|
||||
str, err = Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
// Print messages and return error if any validations failed
|
||||
if len(messages) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
|
||||
sort.Strings(messages)
|
||||
for _, m := range messages {
|
||||
fmt.Fprintln(os.Stderr, " -", m)
|
||||
}
|
||||
smtpConfig.DomainNoStore = str
|
||||
return fmt.Errorf("Failed to validate configuration")
|
||||
}
|
||||
|
||||
option = "max.recipients"
|
||||
smtpConfig.MaxRecipients, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
option = "max.idle.seconds"
|
||||
smtpConfig.MaxIdleSeconds, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
option = "max.message.bytes"
|
||||
smtpConfig.MaxMessageBytes, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
option = "store.messages"
|
||||
flag, err := Config.Bool(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
smtpConfig.StoreMessages = flag
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePop3Config trying to catch config errors early
|
||||
func parsePop3Config() error {
|
||||
pop3Config = new(Pop3Config)
|
||||
section := "pop3"
|
||||
|
||||
// Parse IP4 address only, error on IP6.
|
||||
option := "ip4.address"
|
||||
str, err := Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
addr := net.ParseIP(str)
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err)
|
||||
}
|
||||
pop3Config.Ip4address = addr
|
||||
|
||||
option = "ip4.port"
|
||||
pop3Config.Ip4port, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
option = "domain"
|
||||
str, err = Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
pop3Config.Domain = str
|
||||
|
||||
option = "max.idle.seconds"
|
||||
pop3Config.MaxIdleSeconds, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseWebConfig trying to catch config errors early
|
||||
func parseWebConfig() error {
|
||||
webConfig = new(WebConfig)
|
||||
section := "web"
|
||||
|
||||
// Parse IP4 address only, error on IP6.
|
||||
option := "ip4.address"
|
||||
str, err := Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
addr := net.ParseIP(str)
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, err)
|
||||
}
|
||||
webConfig.Ip4address = addr
|
||||
|
||||
option = "ip4.port"
|
||||
webConfig.Ip4port, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
option = "template.dir"
|
||||
str, err = Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
webConfig.TemplateDir = str
|
||||
|
||||
option = "template.cache"
|
||||
flag, err := Config.Bool(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
webConfig.TemplateCache = flag
|
||||
|
||||
option = "public.dir"
|
||||
str, err = Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
webConfig.PublicDir = str
|
||||
|
||||
option = "greeting.file"
|
||||
str, err = Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
webConfig.GreetingFile = str
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDataStoreConfig trying to catch config errors early
|
||||
func parseDataStoreConfig() error {
|
||||
dataStoreConfig = new(DataStoreConfig)
|
||||
section := "datastore"
|
||||
|
||||
option := "path"
|
||||
str, err := Config.String(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
dataStoreConfig.Path = str
|
||||
|
||||
option = "retention.minutes"
|
||||
dataStoreConfig.RetentionMinutes, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
option = "retention.sleep.millis"
|
||||
dataStoreConfig.RetentionSleep, err = Config.Int(section, option)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireSection checks that a [section] is defined in the configuration file,
|
||||
// appending a message if not.
|
||||
func requireSection(messages *list.List, section string) {
|
||||
if !Config.HasSection(section) {
|
||||
messages.PushBack(fmt.Sprintf("Config section [%v] is required", section))
|
||||
}
|
||||
}
|
||||
|
||||
// requireOption checks that 'option' is defined in [section] of the config file,
|
||||
// appending a message if not.
|
||||
func requireOption(messages *list.List, section string, option string) {
|
||||
if !Config.HasOption(section, option) {
|
||||
messages.PushBack(fmt.Sprintf("Config option '%v' is required in section [%v]", option, section))
|
||||
}
|
||||
}
|
||||
|
||||
53
datastore/datastore.go
Normal file
53
datastore/datastore.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Package datastore contains implementation independent datastore logic
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotExist indicates the requested message does not exist
|
||||
ErrNotExist = errors.New("Message does not exist")
|
||||
|
||||
// ErrNotWritable indicates the message is closed; no longer writable
|
||||
ErrNotWritable = errors.New("Message not writable")
|
||||
)
|
||||
|
||||
// DataStore is an interface to get Mailboxes stored in Inbucket
|
||||
type DataStore interface {
|
||||
MailboxFor(emailAddress string) (Mailbox, error)
|
||||
AllMailboxes() ([]Mailbox, error)
|
||||
}
|
||||
|
||||
// Mailbox is an interface to get and manipulate messages in a DataStore
|
||||
type Mailbox interface {
|
||||
GetMessages() ([]Message, error)
|
||||
GetMessage(id string) (Message, error)
|
||||
Purge() error
|
||||
NewMessage() (Message, error)
|
||||
Name() string
|
||||
String() string
|
||||
}
|
||||
|
||||
// Message is an interface for a single message in a Mailbox
|
||||
type Message interface {
|
||||
ID() string
|
||||
From() string
|
||||
To() []string
|
||||
Date() time.Time
|
||||
Subject() string
|
||||
RawReader() (reader io.ReadCloser, err error)
|
||||
ReadHeader() (msg *mail.Message, err error)
|
||||
ReadBody() (body *enmime.Envelope, err error)
|
||||
ReadRaw() (raw *string, err error)
|
||||
Append(data []byte) error
|
||||
Close() error
|
||||
Delete() error
|
||||
String() string
|
||||
Size() int64
|
||||
}
|
||||
180
datastore/retention.go
Normal file
180
datastore/retention.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"expvar"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
var (
|
||||
retentionScanCompleted = time.Now()
|
||||
retentionScanCompletedMu sync.RWMutex
|
||||
|
||||
// History counters
|
||||
expRetentionDeletesTotal = new(expvar.Int)
|
||||
expRetentionPeriod = new(expvar.Int)
|
||||
expRetainedCurrent = new(expvar.Int)
|
||||
|
||||
// History of certain stats
|
||||
retentionDeletesHist = list.New()
|
||||
retainedHist = list.New()
|
||||
|
||||
// History rendered as comma delimited string
|
||||
expRetentionDeletesHist = new(expvar.String)
|
||||
expRetainedHist = new(expvar.String)
|
||||
)
|
||||
|
||||
func init() {
|
||||
rm := expvar.NewMap("retention")
|
||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
||||
rm.Set("Period", expRetentionPeriod)
|
||||
rm.Set("RetainedHist", expRetainedHist)
|
||||
rm.Set("RetainedCurrent", expRetainedCurrent)
|
||||
|
||||
log.AddTickerFunc(func() {
|
||||
expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal))
|
||||
expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent))
|
||||
})
|
||||
}
|
||||
|
||||
// RetentionScanner looks for messages older than the configured retention period and deletes them.
|
||||
type RetentionScanner struct {
|
||||
globalShutdown chan bool // Closes when Inbucket needs to shut down
|
||||
retentionShutdown chan bool // Closed after the scanner has shut down
|
||||
ds DataStore
|
||||
retentionPeriod time.Duration
|
||||
retentionSleep time.Duration
|
||||
}
|
||||
|
||||
// NewRetentionScanner launches a go-routine that scans for expired
|
||||
// messages, following the configured interval
|
||||
func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner {
|
||||
cfg := config.GetDataStoreConfig()
|
||||
rs := &RetentionScanner{
|
||||
globalShutdown: shutdownChannel,
|
||||
retentionShutdown: make(chan bool),
|
||||
ds: ds,
|
||||
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
|
||||
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
|
||||
}
|
||||
// expRetentionPeriod is displayed on the status page
|
||||
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
|
||||
return rs
|
||||
}
|
||||
|
||||
// Start up the retention scanner if retention period > 0
|
||||
func (rs *RetentionScanner) Start() {
|
||||
if rs.retentionPeriod <= 0 {
|
||||
log.Infof("Retention scanner disabled")
|
||||
close(rs.retentionShutdown)
|
||||
return
|
||||
}
|
||||
log.Infof("Retention configured for %v", rs.retentionPeriod)
|
||||
go rs.run()
|
||||
}
|
||||
|
||||
// run loops to kick off the scanner on the correct schedule
|
||||
func (rs *RetentionScanner) run() {
|
||||
start := time.Now()
|
||||
retentionLoop:
|
||||
for {
|
||||
// Prevent scanner from starting more than once a minute
|
||||
since := time.Since(start)
|
||||
if since < time.Minute {
|
||||
dur := time.Minute - since
|
||||
log.Tracef("Retention scanner sleeping for %v", dur)
|
||||
select {
|
||||
case <-rs.globalShutdown:
|
||||
break retentionLoop
|
||||
case <-time.After(dur):
|
||||
}
|
||||
}
|
||||
// Kickoff scan
|
||||
start = time.Now()
|
||||
if err := rs.doScan(); err != nil {
|
||||
log.Errorf("Error during retention scan: %v", err)
|
||||
}
|
||||
// Check for global shutdown
|
||||
select {
|
||||
case <-rs.globalShutdown:
|
||||
break retentionLoop
|
||||
default:
|
||||
}
|
||||
}
|
||||
log.Tracef("Retention scanner shut down")
|
||||
close(rs.retentionShutdown)
|
||||
}
|
||||
|
||||
// doScan does a single pass of all mailboxes looking for messages that can be purged
|
||||
func (rs *RetentionScanner) doScan() error {
|
||||
log.Tracef("Starting retention scan")
|
||||
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
|
||||
mboxes, err := rs.ds.AllMailboxes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retained := 0
|
||||
// Loop over all mailboxes
|
||||
for _, mb := range mboxes {
|
||||
messages, err := mb.GetMessages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Loop over all messages in mailbox
|
||||
for _, msg := range messages {
|
||||
if msg.Date().Before(cutoff) {
|
||||
log.Tracef("Purging expired message %v", msg.ID())
|
||||
err = msg.Delete()
|
||||
if err != nil {
|
||||
// Log but don't abort
|
||||
log.Errorf("Failed to purge message %v: %v", msg.ID(), err)
|
||||
} else {
|
||||
expRetentionDeletesTotal.Add(1)
|
||||
}
|
||||
} else {
|
||||
retained++
|
||||
}
|
||||
}
|
||||
// Sleep after completing a mailbox
|
||||
select {
|
||||
case <-rs.globalShutdown:
|
||||
log.Tracef("Retention scan aborted due to shutdown")
|
||||
return nil
|
||||
case <-time.After(rs.retentionSleep):
|
||||
// Reduce disk thrashing
|
||||
}
|
||||
}
|
||||
// Update metrics
|
||||
setRetentionScanCompleted(time.Now())
|
||||
expRetainedCurrent.Set(int64(retained))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Join does not retun until the retention scanner has shut down
|
||||
func (rs *RetentionScanner) Join() {
|
||||
if rs.retentionShutdown != nil {
|
||||
<-rs.retentionShutdown
|
||||
}
|
||||
}
|
||||
|
||||
func setRetentionScanCompleted(t time.Time) {
|
||||
retentionScanCompletedMu.Lock()
|
||||
defer retentionScanCompletedMu.Unlock()
|
||||
retentionScanCompleted = t
|
||||
}
|
||||
|
||||
func getRetentionScanCompleted() time.Time {
|
||||
retentionScanCompletedMu.RLock()
|
||||
defer retentionScanCompletedMu.RUnlock()
|
||||
return retentionScanCompleted
|
||||
}
|
||||
|
||||
func secondsSinceRetentionScanCompleted() interface{} {
|
||||
return time.Since(getRetentionScanCompleted()) / time.Second
|
||||
}
|
||||
67
datastore/retention_test.go
Normal file
67
datastore/retention_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDoRetentionScan(t *testing.T) {
|
||||
// Create mock objects
|
||||
mds := &MockDataStore{}
|
||||
|
||||
mb1 := &MockMailbox{}
|
||||
mb2 := &MockMailbox{}
|
||||
mb3 := &MockMailbox{}
|
||||
|
||||
// Mockup some different aged messages (num is in hours)
|
||||
new1 := mockMessage(0)
|
||||
new2 := mockMessage(1)
|
||||
new3 := mockMessage(2)
|
||||
old1 := mockMessage(4)
|
||||
old2 := mockMessage(12)
|
||||
old3 := mockMessage(24)
|
||||
|
||||
// First it should ask for all mailboxes
|
||||
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
|
||||
|
||||
// Then for all messages on each box
|
||||
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
|
||||
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
|
||||
mb3.On("GetMessages").Return([]Message{new3}, nil)
|
||||
|
||||
// Test 4 hour retention
|
||||
rs := &RetentionScanner{
|
||||
ds: mds,
|
||||
retentionPeriod: 4*time.Hour - time.Minute,
|
||||
retentionSleep: 0,
|
||||
}
|
||||
if err := rs.doScan(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Check our assertions
|
||||
mds.AssertExpectations(t)
|
||||
mb1.AssertExpectations(t)
|
||||
mb2.AssertExpectations(t)
|
||||
mb3.AssertExpectations(t)
|
||||
|
||||
// Delete should not have been called on new messages
|
||||
new1.AssertNotCalled(t, "Delete")
|
||||
new2.AssertNotCalled(t, "Delete")
|
||||
new3.AssertNotCalled(t, "Delete")
|
||||
|
||||
// Delete should have been called once on old messages
|
||||
old1.AssertNumberOfCalls(t, "Delete", 1)
|
||||
old2.AssertNumberOfCalls(t, "Delete", 1)
|
||||
old3.AssertNumberOfCalls(t, "Delete", 1)
|
||||
}
|
||||
|
||||
// Make a MockMessage of a specific age
|
||||
func mockMessage(ageHours int) *MockMessage {
|
||||
msg := &MockMessage{}
|
||||
msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
|
||||
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
|
||||
msg.On("Delete").Return(nil)
|
||||
return msg
|
||||
}
|
||||
@@ -1,178 +1,156 @@
|
||||
package smtpd
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"io"
|
||||
"net/mail"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestDoRetentionScan(t *testing.T) {
|
||||
// Create mock objects
|
||||
mds := &MockDataStore{}
|
||||
|
||||
mb1 := &MockMailbox{}
|
||||
mb2 := &MockMailbox{}
|
||||
mb3 := &MockMailbox{}
|
||||
|
||||
// Mockup some different aged messages (num is in hours)
|
||||
new1 := mockMessage(0)
|
||||
new2 := mockMessage(1)
|
||||
new3 := mockMessage(2)
|
||||
old1 := mockMessage(4)
|
||||
old2 := mockMessage(12)
|
||||
old3 := mockMessage(24)
|
||||
|
||||
// First it should ask for all mailboxes
|
||||
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
|
||||
|
||||
// Then for all messages on each box
|
||||
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
|
||||
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
|
||||
mb3.On("GetMessages").Return([]Message{new3}, nil)
|
||||
|
||||
// Test 4 hour retention
|
||||
doRetentionScan(mds, 4*time.Hour, 0)
|
||||
|
||||
// Check our assertions
|
||||
mds.AssertExpectations(t)
|
||||
mb1.AssertExpectations(t)
|
||||
mb2.AssertExpectations(t)
|
||||
mb3.AssertExpectations(t)
|
||||
|
||||
// Delete should not have been called on new messages
|
||||
new1.AssertNotCalled(t, "Delete")
|
||||
new2.AssertNotCalled(t, "Delete")
|
||||
new3.AssertNotCalled(t, "Delete")
|
||||
|
||||
// Delete should have been called once on old messages
|
||||
old1.AssertNumberOfCalls(t, "Delete", 1)
|
||||
old2.AssertNumberOfCalls(t, "Delete", 1)
|
||||
old3.AssertNumberOfCalls(t, "Delete", 1)
|
||||
}
|
||||
|
||||
// Make a MockMessage of a specific age
|
||||
func mockMessage(ageHours int) *MockMessage {
|
||||
msg := &MockMessage{}
|
||||
msg.On("Id").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
|
||||
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
|
||||
msg.On("Delete").Return(nil)
|
||||
return msg
|
||||
}
|
||||
|
||||
// Mock DataStore object
|
||||
// MockDataStore is a shared mock for unit testing
|
||||
type MockDataStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// MailboxFor mock function
|
||||
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
|
||||
args := m.Called()
|
||||
args := m.Called(name)
|
||||
return args.Get(0).(Mailbox), args.Error(1)
|
||||
}
|
||||
|
||||
// AllMailboxes mock function
|
||||
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]Mailbox), args.Error(1)
|
||||
}
|
||||
|
||||
// Mock Mailbox object
|
||||
// MockMailbox is a shared mock for unit testing
|
||||
type MockMailbox struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// GetMessages mock function
|
||||
func (m *MockMailbox) GetMessages() ([]Message, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]Message), args.Error(1)
|
||||
}
|
||||
|
||||
// GetMessage mock function
|
||||
func (m *MockMailbox) GetMessage(id string) (Message, error) {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(Message), args.Error(1)
|
||||
}
|
||||
|
||||
// Purge mock function
|
||||
func (m *MockMailbox) Purge() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) NewMessage() Message {
|
||||
// NewMessage mock function
|
||||
func (m *MockMailbox) NewMessage() (Message, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(Message)
|
||||
return args.Get(0).(Message), args.Error(1)
|
||||
}
|
||||
|
||||
// Name mock function
|
||||
func (m *MockMailbox) Name() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// String mock function
|
||||
func (m *MockMailbox) String() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// Mock Message object
|
||||
// MockMessage is a shared mock for unit testing
|
||||
type MockMessage struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockMessage) Id() string {
|
||||
// ID mock function
|
||||
func (m *MockMessage) ID() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// From mock function
|
||||
func (m *MockMessage) From() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// To mock function
|
||||
func (m *MockMessage) To() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
// Date mock function
|
||||
func (m *MockMessage) Date() time.Time {
|
||||
args := m.Called()
|
||||
return args.Get(0).(time.Time)
|
||||
}
|
||||
|
||||
// Subject mock function
|
||||
func (m *MockMessage) Subject() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// ReadHeader mock function
|
||||
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(*mail.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMessage) ReadBody() (body *enmime.MIMEBody, err error) {
|
||||
// ReadBody mock function
|
||||
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(*enmime.MIMEBody), args.Error(1)
|
||||
return args.Get(0).(*enmime.Envelope), args.Error(1)
|
||||
}
|
||||
|
||||
// ReadRaw mock function
|
||||
func (m *MockMessage) ReadRaw() (raw *string, err error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(*string), args.Error(1)
|
||||
}
|
||||
|
||||
// RawReader mock function
|
||||
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(io.ReadCloser), args.Error(1)
|
||||
}
|
||||
|
||||
// Size mock function
|
||||
func (m *MockMessage) Size() int64 {
|
||||
args := m.Called()
|
||||
return int64(args.Int(0))
|
||||
}
|
||||
|
||||
// Append mock function
|
||||
func (m *MockMessage) Append(data []byte) error {
|
||||
// []byte arg seems to mess up testify/mock
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close mock function
|
||||
func (m *MockMessage) Close() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Delete mock function
|
||||
func (m *MockMessage) Delete() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// String mock function
|
||||
func (m *MockMessage) String() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
@@ -4,8 +4,9 @@
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used by directly, but is typically referenced below in %()s format.
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=.
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[logging]
|
||||
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=2500
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# optional: mail sent to accounts at this domain will not be stored,
|
||||
# for mixed use (content and load testing)
|
||||
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=1100
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
||||
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
|
||||
ip4.port=9000
|
||||
|
||||
# Name of web theme to use
|
||||
theme=integral
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
||||
@@ -81,10 +86,27 @@ template.cache=false
|
||||
# Path to the selected themes public (static) files
|
||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can
|
||||
# be moved out of installation dir for customization
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=%(install.dir)s/themes/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
@@ -100,3 +122,8 @@ retention.minutes=0
|
||||
# This should help reduce disk I/O when there are a large number of messages
|
||||
# to purge.
|
||||
retention.sleep.millis=100
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=100
|
||||
|
||||
15
etc/docker/defaults/greeting.html
Normal file
15
etc/docker/defaults/greeting.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<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
|
||||
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
|
||||
href="https://www.docker.com/" target="_blank">Docker</a> container. It is
|
||||
configured to retain messages for a maximum of 3 days, and will enforce a limit
|
||||
of 300 messages per mailbox - the oldest messages will be deleted to stay under
|
||||
that limit.</p>
|
||||
|
||||
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
|
||||
domain will be accepted but not written to disk. Use this domain for load or
|
||||
soak testing your application.</p>
|
||||
131
etc/docker/defaults/inbucket.conf
Normal file
131
etc/docker/defaults/inbucket.conf
Normal file
@@ -0,0 +1,131 @@
|
||||
# inbucket.conf
|
||||
# Configuration for Inbucket inside of Docker
|
||||
#
|
||||
# These should be reasonable defaults for a production install of Inbucket
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=/opt/inbucket
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[logging]
|
||||
|
||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
||||
level=INFO
|
||||
|
||||
#############################################################################
|
||||
[smtp]
|
||||
|
||||
# IPv4 address to listen for SMTP connections on.
|
||||
ip4.address=0.0.0.0
|
||||
|
||||
# IPv4 port to listen for SMTP connections on.
|
||||
ip4.port=10025
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=%(default.domain)s
|
||||
|
||||
# optional: mail sent to accounts at this domain will not be stored,
|
||||
# for mixed use (content and load testing)
|
||||
domain.nostore=bitbucket.local
|
||||
|
||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
||||
# RFC recommends this be at least 100.
|
||||
max.recipients=100
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
||||
max.idle.seconds=300
|
||||
|
||||
# Maximum allowable size of message body in bytes (including attachments)
|
||||
max.message.bytes=2048000
|
||||
|
||||
# Should we place messages into the datastore, or just throw them away
|
||||
# (for load testing): true or false
|
||||
store.messages=true
|
||||
|
||||
#############################################################################
|
||||
[pop3]
|
||||
|
||||
# IPv4 address to listen for POP3 connections on.
|
||||
ip4.address=0.0.0.0
|
||||
|
||||
# IPv4 port to listen for POP3 connections on.
|
||||
ip4.port=10110
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=%(default.domain)s
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
||||
max.idle.seconds=600
|
||||
|
||||
#############################################################################
|
||||
[web]
|
||||
|
||||
# IPv4 address to serve HTTP web interface on
|
||||
ip4.address=0.0.0.0
|
||||
|
||||
# IPv4 port to serve HTTP web interface on
|
||||
ip4.port=10080
|
||||
|
||||
# Name of web theme to use
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
||||
|
||||
# Should we cache parsed templates (set to false during theme dev)
|
||||
template.cache=true
|
||||
|
||||
# Path to the selected themes public (static) files
|
||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=/con/configuration/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
# Path to the datastore, mail will be written into subdirectories
|
||||
path=/con/data
|
||||
|
||||
# How many minutes after receipt should a message be stored until it's
|
||||
# automatically purged. To retain messages until manually deleted, set this
|
||||
# to 0
|
||||
retention.minutes=4320
|
||||
|
||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
||||
# This should help reduce disk I/O when there are a large number of messages
|
||||
# to purge.
|
||||
retention.sleep.millis=100
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=300
|
||||
24
etc/docker/defaults/start-inbucket.sh
Executable file
24
etc/docker/defaults/start-inbucket.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# start-inbucket.sh
|
||||
# description: start inbucket (runs within a docker container)
|
||||
|
||||
CONF_SOURCE="$INBUCKET_HOME/defaults"
|
||||
CONF_TARGET="/con/configuration"
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
install_default_config() {
|
||||
local file="$1"
|
||||
local source="$CONF_SOURCE/$file"
|
||||
local target="$CONF_TARGET/$file"
|
||||
|
||||
if [ ! -e "$target" ]; then
|
||||
echo "Installing default $file to $CONF_TARGET"
|
||||
install "$source" "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
install_default_config "inbucket.conf"
|
||||
install_default_config "greeting.html"
|
||||
|
||||
exec "$INBUCKET_HOME/bin/inbucket" $*
|
||||
62
etc/docker/docker-run.sh
Executable file
62
etc/docker/docker-run.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/bin/sh
|
||||
# docker-run.sh
|
||||
# description: Launch Inbucket's docker image
|
||||
|
||||
# Docker Image Tag
|
||||
IMAGE="jhillyerd/inbucket"
|
||||
|
||||
# Ports exposed on host:
|
||||
PORT_HTTP=9000
|
||||
PORT_SMTP=2500
|
||||
PORT_POP3=1100
|
||||
|
||||
# Volumes exposed on host:
|
||||
VOL_CONFIG="/tmp/inbucket/config"
|
||||
VOL_DATA="/tmp/inbucket/data"
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
main() {
|
||||
local run_opts=""
|
||||
|
||||
for arg in $*; do
|
||||
case "$arg" in
|
||||
-h)
|
||||
usage
|
||||
exit
|
||||
;;
|
||||
-r)
|
||||
reset
|
||||
;;
|
||||
-d)
|
||||
run_opts="$run_opts -d"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
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" \
|
||||
"$IMAGE"
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "$0 [options]" 2>&1
|
||||
echo " -d detach - detach and print container ID" 2>&1
|
||||
echo " -r reset - purge config and data before startup" 2>&1
|
||||
echo " -h help - print this message" 2>&1
|
||||
}
|
||||
|
||||
reset() {
|
||||
/bin/rm -rf "$VOL_CONFIG"
|
||||
/bin/rm -rf "$VOL_DATA"
|
||||
}
|
||||
|
||||
main $*
|
||||
51
etc/docker/install.sh
Executable file
51
etc/docker/install.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
# install.sh
|
||||
# description: Build, test, and install Inbucket. Should be executed inside a Docker container.
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
installdir="$INBUCKET_HOME"
|
||||
srcdir="$INBUCKET_SRC"
|
||||
bindir="$installdir/bin"
|
||||
defaultsdir="$installdir/defaults"
|
||||
contextdir="/con/context"
|
||||
|
||||
echo "### Installing OS Build Dependencies"
|
||||
apk add --no-cache --virtual .build-deps git
|
||||
|
||||
# Setup
|
||||
export GOBIN="$bindir"
|
||||
cd "$srcdir"
|
||||
# Fetch tags for describe
|
||||
git fetch -t
|
||||
builddate="$(date -Iseconds)"
|
||||
buildver="$(git describe --tags --always)"
|
||||
|
||||
# Build
|
||||
go clean
|
||||
echo "### Fetching Dependencies"
|
||||
go get -t -v ./...
|
||||
|
||||
echo "### Testing Inbucket"
|
||||
go test ./...
|
||||
|
||||
echo "### Building Inbucket"
|
||||
go build -o inbucket -ldflags "-X 'main.version=$buildver' -X 'main.date=$builddate'" -v .
|
||||
|
||||
echo "### Installing Inbucket"
|
||||
set -x
|
||||
mkdir -p "$bindir"
|
||||
install inbucket "$bindir"
|
||||
mkdir -p "$contextdir"
|
||||
install etc/docker/defaults/start-inbucket.sh "$contextdir"
|
||||
cp -r themes "$installdir/"
|
||||
mkdir -p "$defaultsdir"
|
||||
cp etc/docker/defaults/inbucket.conf "$defaultsdir"
|
||||
cp etc/docker/defaults/greeting.html "$defaultsdir"
|
||||
set +x
|
||||
|
||||
echo "### Removing OS Build Dependencies"
|
||||
apk del .build-deps
|
||||
|
||||
echo "### Removing $GOPATH"
|
||||
rm -rf "$GOPATH"
|
||||
131
etc/homebrew/inbucket.conf
Normal file
131
etc/homebrew/inbucket.conf
Normal file
@@ -0,0 +1,131 @@
|
||||
# inbucket.conf
|
||||
# homebrew inbucket configuration
|
||||
# {{}} values will be replaced during installation
|
||||
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
default.domain=inbucket.local
|
||||
themes.dir={{HOMEBREW_PREFIX}}/share/inbucket/themes
|
||||
datastore.dir={{HOMEBREW_PREFIX}}/var/inbucket/datastore
|
||||
|
||||
#############################################################################
|
||||
[logging]
|
||||
|
||||
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
|
||||
level=INFO
|
||||
|
||||
#############################################################################
|
||||
[smtp]
|
||||
|
||||
# IPv4 address to listen for SMTP connections on.
|
||||
ip4.address=0.0.0.0
|
||||
|
||||
# IPv4 port to listen for SMTP connections on.
|
||||
ip4.port=2500
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=%(default.domain)s
|
||||
|
||||
# optional: mail sent to accounts at this domain will not be stored,
|
||||
# for mixed use (content and load testing)
|
||||
domain.nostore=bitbucket.local
|
||||
|
||||
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
|
||||
# RFC recommends this be at least 100.
|
||||
max.recipients=100
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
|
||||
max.idle.seconds=300
|
||||
|
||||
# Maximum allowable size of message body in bytes (including attachments)
|
||||
max.message.bytes=2048000
|
||||
|
||||
# Should we place messages into the datastore, or just throw them away
|
||||
# (for load testing): true or false
|
||||
store.messages=true
|
||||
|
||||
#############################################################################
|
||||
[pop3]
|
||||
|
||||
# IPv4 address to listen for POP3 connections on.
|
||||
ip4.address=0.0.0.0
|
||||
|
||||
# IPv4 port to listen for POP3 connections on.
|
||||
ip4.port=1100
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=%(default.domain)s
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
||||
max.idle.seconds=600
|
||||
|
||||
#############################################################################
|
||||
[web]
|
||||
|
||||
# IPv4 address to serve HTTP web interface on
|
||||
ip4.address=0.0.0.0
|
||||
|
||||
# IPv4 port to serve HTTP web interface on
|
||||
ip4.port=9000
|
||||
|
||||
# Name of web theme to use
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(themes.dir)s/%(theme)s/templates
|
||||
|
||||
# Should we cache parsed templates (set to false during theme dev)
|
||||
template.cache=true
|
||||
|
||||
# Path to the selected themes public (static) files
|
||||
public.dir=%(themes.dir)s/%(theme)s/public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=%(themes.dir)s/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
# Path to the datastore, mail will be written into subdirectories
|
||||
path=%(datastore.dir)s
|
||||
|
||||
# How many minutes after receipt should a message be stored until it's
|
||||
# automatically purged. To retain messages until manually deleted, set this
|
||||
# to 0
|
||||
retention.minutes=10080
|
||||
|
||||
# How many milliseconds to sleep after purging messages from a mailbox.
|
||||
# This should help reduce disk I/O when there are a large number of messages
|
||||
# to purge.
|
||||
retention.sleep.millis=100
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=100
|
||||
@@ -4,8 +4,9 @@
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used by directly, but is typically referenced below in %()s format.
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=.
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[logging]
|
||||
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=2500
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# optional: mail sent to accounts at this domain will not be stored,
|
||||
# for mixed use (content and load testing)
|
||||
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=1100
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
||||
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
|
||||
ip4.port=9000
|
||||
|
||||
# Name of web theme to use
|
||||
theme=integral
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
||||
@@ -81,10 +86,27 @@ template.cache=true
|
||||
# Path to the selected themes public (static) files
|
||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can
|
||||
# be moved out of installation dir for customization
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=%(install.dir)s/themes/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
@@ -100,3 +122,8 @@ retention.minutes=240
|
||||
# This should help reduce disk I/O when there are a large number of messages
|
||||
# to purge.
|
||||
retention.sleep.millis=100
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=500
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1
|
||||
@@ -1 +0,0 @@
|
||||
curl -i -H "Accept: application/json" --noproxy localhost -X DELETE http://localhost:9000/mailbox/$1
|
||||
@@ -1 +0,0 @@
|
||||
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1/$2
|
||||
@@ -1 +0,0 @@
|
||||
curl -i -H "Accept: application/json" --noproxy localhost -X DELETE http://localhost:9000/mailbox/$1/$2
|
||||
@@ -1 +0,0 @@
|
||||
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1/$2/source
|
||||
@@ -1,3 +0,0 @@
|
||||
Please see the RedHat installation guide on our website:
|
||||
|
||||
http://jhillyerd.github.com/inbucket/installation/redhat.html
|
||||
3
etc/redhat/README
Normal file
3
etc/redhat/README
Normal file
@@ -0,0 +1,3 @@
|
||||
Please see the RedHat installation guide on our website:
|
||||
|
||||
http://www.inbucket.org/installation/redhat.html
|
||||
@@ -3,6 +3,6 @@
|
||||
notifempty
|
||||
create 0644 inbucket inbucket
|
||||
postrotate
|
||||
[ -x /sbin/reload ] && /sbin/reload inbucket >/dev/null 2>&1 || true
|
||||
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
|
||||
endscript
|
||||
}
|
||||
20
etc/redhat/inbucket.service
Normal file
20
etc/redhat/inbucket.service
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Inbucket Disposable Email Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=inbucket
|
||||
Group=inbucket
|
||||
|
||||
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
|
||||
|
||||
# Re-open log file after rotation
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
# Give SMTP connections time to drain
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
118
etc/rest-apiv1.sh
Executable file
118
etc/rest-apiv1.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# rest-apiv1.sh
|
||||
# description: Script to access Inbucket REST API version 1
|
||||
|
||||
API_HOST="localhost"
|
||||
URL_ROOT="http://$API_HOST:9000/api/v1"
|
||||
|
||||
set -eo pipefail
|
||||
[ $TRACE ] && set -x
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <command> [argument1 [argument2 [..]]]" >&2
|
||||
echo >&2
|
||||
echo "Options:" >&2
|
||||
echo " -h - show this help" >&2
|
||||
echo " -i - show HTTP headers" >&2
|
||||
echo >&2
|
||||
echo "Commands:" >&2
|
||||
echo " list <mailbox> - list mailbox contents" >&2
|
||||
echo " body <mailbox> <id> - print message body" >&2
|
||||
echo " source <mailbox> <id> - print message source" >&2
|
||||
echo " delete <mailbox> <id> - delete message" >&2
|
||||
echo " purge <mailbox> - delete all messages in mailbox" >&2
|
||||
}
|
||||
|
||||
arg_check() {
|
||||
declare command="$1" expected="$2" received="$3"
|
||||
if [ $expected != $received ]; then
|
||||
echo "Error: Command '$command' requires $expected arguments, but received $received" >&2
|
||||
echo >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
# Process options
|
||||
local curl_opts=""
|
||||
local pretty="true"
|
||||
for arg in $*; do
|
||||
if [[ $arg == -* ]]; then
|
||||
case "$arg" in
|
||||
-h)
|
||||
usage
|
||||
exit
|
||||
;;
|
||||
-i)
|
||||
curl_opts="$curl_opts -i"
|
||||
pretty=""
|
||||
;;
|
||||
**)
|
||||
echo "Unknown option: $arg" >&2
|
||||
echo
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Store command
|
||||
declare command="$1"
|
||||
shift
|
||||
|
||||
local url=""
|
||||
local method="GET"
|
||||
local is_json=""
|
||||
|
||||
case "$command" in
|
||||
body)
|
||||
arg_check "$command" 2 $#
|
||||
url="$URL_ROOT/mailbox/$1/$2"
|
||||
is_json="true"
|
||||
;;
|
||||
delete)
|
||||
arg_check "$command" 2 $#
|
||||
method=DELETE
|
||||
url="$URL_ROOT/mailbox/$1/$2"
|
||||
;;
|
||||
list)
|
||||
arg_check "$command" 1 $#
|
||||
url="$URL_ROOT/mailbox/$1"
|
||||
is_json="true"
|
||||
;;
|
||||
purge)
|
||||
arg_check "$command" 1 $#
|
||||
method=DELETE
|
||||
url="$URL_ROOT/mailbox/$1"
|
||||
;;
|
||||
source)
|
||||
arg_check "$command" 2 $#
|
||||
url="$URL_ROOT/mailbox/$1/$2/source"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command $command" >&2
|
||||
echo >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Use jq to pretty-print if installed and we are expecting JSON output
|
||||
if [ $pretty ] && [ $is_json ] && type -P jq >/dev/null; then
|
||||
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url" | jq .
|
||||
else
|
||||
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
main $*
|
||||
10
etc/travis-deploy.sh
Executable file
10
etc/travis-deploy.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# travis-deploy.sh
|
||||
# description: Trigger goreleaser deployment in correct build scenarios
|
||||
|
||||
set -eo pipefail
|
||||
set -x
|
||||
|
||||
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
|
||||
curl -sL https://git.io/goreleaser | bash
|
||||
fi
|
||||
@@ -1,3 +0,0 @@
|
||||
Please see the Ubuntu installation guide on our website:
|
||||
|
||||
http://jhillyerd.github.com/inbucket/installation/ubuntu.html
|
||||
3
etc/ubuntu/README
Normal file
3
etc/ubuntu/README
Normal file
@@ -0,0 +1,3 @@
|
||||
Please see the Ubuntu installation guide on our website:
|
||||
|
||||
http://www.inbucket.org/installation/ubuntu.html
|
||||
@@ -3,6 +3,6 @@
|
||||
notifempty
|
||||
create 0644 inbucket inbucket
|
||||
postrotate
|
||||
[ -e /etc/init.d/inbucket ] && /etc/init.d/inbucket reload >/dev/null 2>&1 || true
|
||||
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
|
||||
endscript
|
||||
}
|
||||
20
etc/ubuntu/inbucket.service
Normal file
20
etc/ubuntu/inbucket.service
Normal file
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Inbucket Disposable Email Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=inbucket
|
||||
Group=inbucket
|
||||
|
||||
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
|
||||
|
||||
# Re-open log file after rotation
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
# Give SMTP connections time to drain
|
||||
TimeoutStopSec=20
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -4,8 +4,9 @@
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used by directly, but is typically referenced below in %()s format.
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=/opt/inbucket
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[logging]
|
||||
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=25
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# optional: mail sent to accounts at this domain will not be stored,
|
||||
# for mixed use (content and load testing)
|
||||
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=110
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
||||
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
|
||||
ip4.port=80
|
||||
|
||||
# Name of web theme to use
|
||||
theme=integral
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(install.dir)s/themes/%(theme)s/templates
|
||||
@@ -81,10 +86,27 @@ template.cache=true
|
||||
# Path to the selected themes public (static) files
|
||||
public.dir=%(install.dir)s/themes/%(theme)s/public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can
|
||||
# be moved out of installation dir for customization
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=%(install.dir)s/themes/greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
@@ -100,3 +122,8 @@ retention.minutes=240
|
||||
# This should help reduce disk I/O when there are a large number of messages
|
||||
# to purge.
|
||||
retention.sleep.millis=100
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=500
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
#############################################################################
|
||||
[DEFAULT]
|
||||
|
||||
# Not used by directly, but is typically referenced below in %()s format.
|
||||
# Not used directly, but is typically referenced below in %()s format.
|
||||
install.dir=.
|
||||
default.domain=inbucket.local
|
||||
|
||||
#############################################################################
|
||||
[logging]
|
||||
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=2500
|
||||
|
||||
# used in SMTP greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# optional: mail sent to accounts at this domain will not be stored,
|
||||
# for mixed use (content and load testing)
|
||||
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
|
||||
ip4.port=1100
|
||||
|
||||
# used in POP3 greeting
|
||||
domain=inbucket.local
|
||||
domain=%(default.domain)s
|
||||
|
||||
# How long we allow a network connection to be idle before hanging up on the
|
||||
# client, POP3 RFC requires at least 10 minutes (600 seconds).
|
||||
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
|
||||
ip4.port=9000
|
||||
|
||||
# Name of web theme to use
|
||||
theme=integral
|
||||
theme=bootstrap
|
||||
|
||||
# Prompt displayed between the mailbox entry field and View button. Leave
|
||||
# empty or comment out to hide the prompt.
|
||||
mailbox.prompt=@inbucket
|
||||
|
||||
# Path to the selected themes template files
|
||||
template.dir=%(install.dir)s\themes\%(theme)s\templates
|
||||
@@ -81,10 +86,27 @@ template.cache=true
|
||||
# Path to the selected themes public (static) files
|
||||
public.dir=%(install.dir)s\themes\%(theme)s\public
|
||||
|
||||
# Path to the greeting HTML displayed on front page, can
|
||||
# be moved out of installation dir for customization
|
||||
# Path to the greeting HTML displayed on front page, can be moved out of
|
||||
# installation dir for customization
|
||||
greeting.file=%(install.dir)s\themes\greeting.html
|
||||
|
||||
# Key used to sign session cookie data so that it cannot be tampered with.
|
||||
# If this is left unset, Inbucket will generate a random key at startup
|
||||
# and previous sessions will be invalidated.
|
||||
#cookie.auth.key=secret-inbucket-session-cookie-key
|
||||
|
||||
# Enable or disable the live message monitor tab for the web UI. This will let
|
||||
# anybody see all messages delivered to Inbucket. This setting has no impact
|
||||
# on the availability of the underlying WebSocket.
|
||||
monitor.visible=true
|
||||
|
||||
# How many historical message headers should be cached for display by new
|
||||
# monitor connections. It does not limit the number of messages displayed by
|
||||
# the browser once the monitor is open; all freshly received messages will be
|
||||
# appended to the on screen list. This setting also affects the underlying
|
||||
# API/WebSocket.
|
||||
monitor.history=30
|
||||
|
||||
#############################################################################
|
||||
[datastore]
|
||||
|
||||
@@ -100,3 +122,8 @@ retention.minutes=240
|
||||
# This should help reduce disk I/O when there are a large number of messages
|
||||
# to purge.
|
||||
retention.sleep.millis=100
|
||||
|
||||
# Maximum number of messages we will store in a single mailbox. If this
|
||||
# number is exceeded, the oldest message in the box will be deleted each
|
||||
# time a new message is received for it.
|
||||
mailbox.message.cap=500
|
||||
|
||||
270
filestore/fmessage.go
Normal file
270
filestore/fmessage.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package filestore
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
// FileMessage implements Message and contains a little bit of data about a
|
||||
// particular email message, and methods to retrieve the rest of it from disk.
|
||||
type FileMessage struct {
|
||||
mailbox *FileMailbox
|
||||
// Stored in GOB
|
||||
Fid string
|
||||
Fdate time.Time
|
||||
Ffrom string
|
||||
Fto []string
|
||||
Fsubject string
|
||||
Fsize int64
|
||||
// These are for creating new messages only
|
||||
writable bool
|
||||
writerFile *os.File
|
||||
writer *bufio.Writer
|
||||
}
|
||||
|
||||
// NewMessage creates a new FileMessage object and sets the Date and Id fields.
|
||||
// It will also delete messages over messageCap if configured.
|
||||
func (mb *FileMailbox) NewMessage() (datastore.Message, error) {
|
||||
// Load index
|
||||
if !mb.indexLoaded {
|
||||
if err := mb.readIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete old messages over messageCap
|
||||
if mb.store.messageCap > 0 {
|
||||
for len(mb.messages) >= mb.store.messageCap {
|
||||
log.Infof("Mailbox %q over configured message cap", mb.name)
|
||||
if err := mb.messages[0].Delete(); err != nil {
|
||||
log.Errorf("Error deleting message: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
date := time.Now()
|
||||
id := generateID(date)
|
||||
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}, nil
|
||||
}
|
||||
|
||||
// ID gets the ID of the Message
|
||||
func (m *FileMessage) ID() string {
|
||||
return m.Fid
|
||||
}
|
||||
|
||||
// Date returns the date/time this Message was received by Inbucket
|
||||
func (m *FileMessage) Date() time.Time {
|
||||
return m.Fdate
|
||||
}
|
||||
|
||||
// From returns the value of the Message From header
|
||||
func (m *FileMessage) From() string {
|
||||
return m.Ffrom
|
||||
}
|
||||
|
||||
// To returns the value of the Message To header
|
||||
func (m *FileMessage) To() []string {
|
||||
return m.Fto
|
||||
}
|
||||
|
||||
// Subject returns the value of the Message Subject header
|
||||
func (m *FileMessage) Subject() string {
|
||||
return m.Fsubject
|
||||
}
|
||||
|
||||
// String returns a string in the form: "Subject()" from From()
|
||||
func (m *FileMessage) String() string {
|
||||
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
|
||||
}
|
||||
|
||||
// Size returns the size of the Message on disk in bytes
|
||||
func (m *FileMessage) Size() int64 {
|
||||
return m.Fsize
|
||||
}
|
||||
|
||||
func (m *FileMessage) rawPath() string {
|
||||
return filepath.Join(m.mailbox.path, m.Fid+".raw")
|
||||
}
|
||||
|
||||
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
|
||||
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
|
||||
file, err := os.Open(m.rawPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
return mail.ReadMessage(reader)
|
||||
}
|
||||
|
||||
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
|
||||
func (m *FileMessage) ReadBody() (body *enmime.Envelope, err error) {
|
||||
file, err := os.Open(m.rawPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
mime, err := enmime.ReadEnvelope(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mime, nil
|
||||
}
|
||||
|
||||
// RawReader opens the .raw portion of a Message as an io.ReadCloser
|
||||
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
|
||||
file, err := os.Open(m.rawPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// ReadRaw opens the .raw portion of a Message and returns it as a string
|
||||
func (m *FileMessage) ReadRaw() (raw *string, err error) {
|
||||
reader, err := m.RawReader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyString := string(bodyBytes)
|
||||
return &bodyString, nil
|
||||
}
|
||||
|
||||
// Append data to a newly opened Message, this will fail on a pre-existing Message and
|
||||
// after Close() is called.
|
||||
func (m *FileMessage) Append(data []byte) error {
|
||||
// Prevent Appending to a pre-existing Message
|
||||
if !m.writable {
|
||||
return datastore.ErrNotWritable
|
||||
}
|
||||
// Open file for writing if we haven't yet
|
||||
if m.writer == nil {
|
||||
// Ensure mailbox directory exists
|
||||
if err := m.mailbox.createDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(m.rawPath())
|
||||
if err != nil {
|
||||
// Set writable false just in case something calls me a million times
|
||||
m.writable = false
|
||||
return err
|
||||
}
|
||||
m.writerFile = file
|
||||
m.writer = bufio.NewWriter(file)
|
||||
}
|
||||
_, err := m.writer.Write(data)
|
||||
m.Fsize += int64(len(data))
|
||||
return err
|
||||
}
|
||||
|
||||
// Close this Message for writing - no more data may be Appended. Close() will also
|
||||
// trigger the creation of the .gob file.
|
||||
func (m *FileMessage) Close() error {
|
||||
// nil out the writer fields so they can't be used
|
||||
writer := m.writer
|
||||
writerFile := m.writerFile
|
||||
m.writer = nil
|
||||
m.writerFile = nil
|
||||
|
||||
if writer != nil {
|
||||
if err := writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if writerFile != nil {
|
||||
if err := writerFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch headers
|
||||
body, err := m.ReadBody()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only public fields are stored in gob, hence starting with capital F
|
||||
// Parse From address
|
||||
if address, err := mail.ParseAddress(body.GetHeader("From")); err == nil {
|
||||
m.Ffrom = address.String()
|
||||
} else {
|
||||
m.Ffrom = body.GetHeader("From")
|
||||
}
|
||||
m.Fsubject = body.GetHeader("Subject")
|
||||
|
||||
// Turn the To header into a slice
|
||||
if addresses, err := body.AddressList("To"); err == nil {
|
||||
for _, a := range addresses {
|
||||
m.Fto = append(m.Fto, a.String())
|
||||
}
|
||||
} else {
|
||||
m.Fto = []string{body.GetHeader("To")}
|
||||
}
|
||||
|
||||
// Refresh the index before adding our message
|
||||
err = m.mailbox.readIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Made it this far without errors, add it to the index
|
||||
m.mailbox.messages = append(m.mailbox.messages, m)
|
||||
return m.mailbox.writeIndex()
|
||||
}
|
||||
|
||||
// Delete this Message from disk by removing it from the index and deleting the
|
||||
// raw files.
|
||||
func (m *FileMessage) Delete() error {
|
||||
messages := m.mailbox.messages
|
||||
for i, mm := range messages {
|
||||
if m == mm {
|
||||
// Slice around message we are deleting
|
||||
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := m.mailbox.writeIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(m.mailbox.messages) == 0 {
|
||||
// This was the last message, thus writeIndex() has removed the entire
|
||||
// directory; we don't need to delete the raw file.
|
||||
return nil
|
||||
}
|
||||
|
||||
// There are still messages in the index
|
||||
log.Tracef("Deleting %v", m.rawPath())
|
||||
return os.Remove(m.rawPath())
|
||||
}
|
||||
357
filestore/fstore.go
Normal file
357
filestore/fstore.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package filestore
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/stringutil"
|
||||
)
|
||||
|
||||
// Name of index file in each mailbox
|
||||
const indexFileName = "index.gob"
|
||||
|
||||
var (
|
||||
// indexMx is locked while reading/writing an index file
|
||||
//
|
||||
// NOTE: This is a bottleneck because it's a single lock even if we have a
|
||||
// million index files
|
||||
indexMx = new(sync.RWMutex)
|
||||
|
||||
// dirMx is locked while creating/removing directories
|
||||
dirMx = new(sync.Mutex)
|
||||
|
||||
// countChannel is filled with a sequential numbers (0000..9999), which are
|
||||
// used by generateID() to generate unique message IDs. It's global
|
||||
// because we only want one regardless of the number of DataStore objects
|
||||
countChannel = make(chan int, 10)
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Start generator
|
||||
go countGenerator(countChannel)
|
||||
}
|
||||
|
||||
// Populates the channel with numbers
|
||||
func countGenerator(c chan int) {
|
||||
for i := 0; true; i = (i + 1) % 10000 {
|
||||
c <- i
|
||||
}
|
||||
}
|
||||
|
||||
// FileDataStore implements DataStore aand is the root of the mail storage
|
||||
// hiearchy. It provides access to Mailbox objects
|
||||
type FileDataStore struct {
|
||||
path string
|
||||
mailPath string
|
||||
messageCap int
|
||||
}
|
||||
|
||||
// NewFileDataStore creates a new DataStore object using the specified path
|
||||
func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore {
|
||||
path := cfg.Path
|
||||
if path == "" {
|
||||
log.Errorf("No value configured for datastore path")
|
||||
return nil
|
||||
}
|
||||
mailPath := filepath.Join(path, "mail")
|
||||
if _, err := os.Stat(mailPath); err != nil {
|
||||
// Mail datastore does not yet exist
|
||||
if err = os.MkdirAll(mailPath, 0770); err != nil {
|
||||
log.Errorf("Error creating dir %q: %v", mailPath, err)
|
||||
}
|
||||
}
|
||||
return &FileDataStore{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}
|
||||
}
|
||||
|
||||
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
|
||||
// construct it's path.
|
||||
func DefaultFileDataStore() datastore.DataStore {
|
||||
cfg := config.GetDataStoreConfig()
|
||||
return NewFileDataStore(cfg)
|
||||
}
|
||||
|
||||
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
|
||||
// does not exist, it will attempt to create it.
|
||||
func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
|
||||
name, err := stringutil.ParseMailboxName(emailAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := stringutil.HashMailboxName(name)
|
||||
s1 := dir[0:3]
|
||||
s2 := dir[0:6]
|
||||
path := filepath.Join(ds.mailPath, s1, s2, dir)
|
||||
indexPath := filepath.Join(path, indexFileName)
|
||||
|
||||
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
|
||||
indexPath: indexPath}, nil
|
||||
}
|
||||
|
||||
// AllMailboxes returns a slice with all Mailboxes
|
||||
func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
|
||||
mailboxes := make([]datastore.Mailbox, 0, 100)
|
||||
infos1, err := ioutil.ReadDir(ds.mailPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Loop over level 1 directories
|
||||
for _, inf1 := range infos1 {
|
||||
if inf1.IsDir() {
|
||||
l1 := inf1.Name()
|
||||
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
for _, inf2 := range infos2 {
|
||||
if inf2.IsDir() {
|
||||
l2 := inf2.Name()
|
||||
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Loop over mailboxes
|
||||
for _, inf3 := range infos3 {
|
||||
if inf3.IsDir() {
|
||||
mbdir := inf3.Name()
|
||||
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
|
||||
idx := filepath.Join(mbpath, indexFileName)
|
||||
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
|
||||
indexPath: idx}
|
||||
mailboxes = append(mailboxes, mb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mailboxes, nil
|
||||
}
|
||||
|
||||
// FileMailbox implements Mailbox, manages the mail for a specific user and
|
||||
// correlates to a particular directory on disk.
|
||||
type FileMailbox struct {
|
||||
store *FileDataStore
|
||||
name string
|
||||
dirName string
|
||||
path string
|
||||
indexLoaded bool
|
||||
indexPath string
|
||||
messages []*FileMessage
|
||||
}
|
||||
|
||||
// Name of the mailbox
|
||||
func (mb *FileMailbox) Name() string {
|
||||
return mb.name
|
||||
}
|
||||
|
||||
// String renders the name and directory path of the mailbox
|
||||
func (mb *FileMailbox) String() string {
|
||||
return mb.name + "[" + mb.dirName + "]"
|
||||
}
|
||||
|
||||
// GetMessages scans the mailbox directory for .gob files and decodes them into
|
||||
// a slice of Message objects.
|
||||
func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
|
||||
if !mb.indexLoaded {
|
||||
if err := mb.readIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
messages := make([]datastore.Message, len(mb.messages))
|
||||
for i, m := range mb.messages {
|
||||
messages[i] = m
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetMessage decodes a single message by Id and returns a Message object
|
||||
func (mb *FileMailbox) GetMessage(id string) (datastore.Message, error) {
|
||||
if !mb.indexLoaded {
|
||||
if err := mb.readIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if id == "latest" && len(mb.messages) != 0 {
|
||||
return mb.messages[len(mb.messages)-1], nil
|
||||
}
|
||||
|
||||
for _, m := range mb.messages {
|
||||
if m.Fid == id {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, datastore.ErrNotExist
|
||||
}
|
||||
|
||||
// Purge deletes all messages in this mailbox
|
||||
func (mb *FileMailbox) Purge() error {
|
||||
mb.messages = mb.messages[:0]
|
||||
return mb.writeIndex()
|
||||
}
|
||||
|
||||
// readIndex loads the mailbox index data from disk
|
||||
func (mb *FileMailbox) readIndex() error {
|
||||
// Clear message slice, open index
|
||||
mb.messages = mb.messages[:0]
|
||||
// Lock for reading
|
||||
indexMx.RLock()
|
||||
defer indexMx.RUnlock()
|
||||
// Check if index exists
|
||||
if _, err := os.Stat(mb.indexPath); err != nil {
|
||||
// Does not exist, but that's not an error in our world
|
||||
log.Tracef("Index %v does not exist (yet)", mb.indexPath)
|
||||
mb.indexLoaded = true
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(mb.indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Decode gob data
|
||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
||||
for {
|
||||
msg := new(FileMessage)
|
||||
if err = dec.Decode(msg); err != nil {
|
||||
if err == io.EOF {
|
||||
// It's OK to get an EOF here
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
|
||||
}
|
||||
msg.mailbox = mb
|
||||
mb.messages = append(mb.messages, msg)
|
||||
}
|
||||
|
||||
mb.indexLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIndex overwrites the index on disk with the current mailbox data
|
||||
func (mb *FileMailbox) writeIndex() error {
|
||||
// Lock for writing
|
||||
indexMx.Lock()
|
||||
defer indexMx.Unlock()
|
||||
if len(mb.messages) > 0 {
|
||||
// Ensure mailbox directory exists
|
||||
if err := mb.createDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Open index for writing
|
||||
file, err := os.Create(mb.indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writer := bufio.NewWriter(file)
|
||||
// Write each message and then flush
|
||||
enc := gob.NewEncoder(writer)
|
||||
for _, m := range mb.messages {
|
||||
err = enc.Encode(m)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// No messages, delete index+maildir
|
||||
log.Tracef("Removing mailbox %v", mb.path)
|
||||
return mb.removeDir()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDir checks for the presence of the path for this mailbox, creates it if needed
|
||||
func (mb *FileMailbox) createDir() error {
|
||||
dirMx.Lock()
|
||||
defer dirMx.Unlock()
|
||||
if _, err := os.Stat(mb.path); err != nil {
|
||||
if err := os.MkdirAll(mb.path, 0770); err != nil {
|
||||
log.Errorf("Failed to create directory %v, %v", mb.path, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDir removes the mailbox, plus empty higher level directories
|
||||
func (mb *FileMailbox) removeDir() error {
|
||||
dirMx.Lock()
|
||||
defer dirMx.Unlock()
|
||||
// remove mailbox dir, including index file
|
||||
if err := os.RemoveAll(mb.path); err != nil {
|
||||
return err
|
||||
}
|
||||
// remove parents if empty
|
||||
dir := filepath.Dir(mb.path)
|
||||
if removeDirIfEmpty(dir) {
|
||||
removeDirIfEmpty(filepath.Dir(dir))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
|
||||
// Caller should hold dirMx. Returns true if dir was removed.
|
||||
func removeDirIfEmpty(path string) (removed bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
files, err := f.Readdirnames(0)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(files) > 0 {
|
||||
// Dir not empty
|
||||
return false
|
||||
}
|
||||
log.Tracef("Removing dir %v", path)
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to remove %q: %v", path, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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.
|
||||
func generatePrefix(date time.Time) string {
|
||||
return date.Format("20060102T150405")
|
||||
}
|
||||
|
||||
// generateId adds a 4-digit unique number onto the end of the string
|
||||
// returned by generatePrefix()
|
||||
func generateID(date time.Time) string {
|
||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package smtpd
|
||||
package filestore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
@@ -11,11 +10,14 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Test directory structure created by filestore
|
||||
func TestFSDirStructure(t *testing.T) {
|
||||
ds, logbuf := setupDataStore()
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
root := ds.path
|
||||
|
||||
@@ -93,13 +95,13 @@ func TestFSDirStructure(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test FileDataStore.AllMailboxes()
|
||||
func TestFSAllMailboxes(t *testing.T) {
|
||||
ds, logbuf := setupDataStore()
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
|
||||
@@ -120,14 +122,14 @@ func TestFSAllMailboxes(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test delivering several messages to the same mailbox, meanwhile querying its
|
||||
// contents with a new mailbox object each time
|
||||
func TestFSDeliverMany(t *testing.T) {
|
||||
ds, logbuf := setupDataStore()
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "fred"
|
||||
@@ -170,13 +172,13 @@ func TestFSDeliverMany(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test deleting messages
|
||||
func TestFSDelete(t *testing.T) {
|
||||
ds, logbuf := setupDataStore()
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "fred"
|
||||
@@ -199,8 +201,8 @@ func TestFSDelete(t *testing.T) {
|
||||
len(subjects), len(msgs))
|
||||
|
||||
// Delete a couple messages
|
||||
msgs[1].Delete()
|
||||
msgs[3].Delete()
|
||||
_ = msgs[1].Delete()
|
||||
_ = msgs[3].Delete()
|
||||
|
||||
// Confirm deletion
|
||||
mb, err = ds.MailboxFor(mbName)
|
||||
@@ -244,13 +246,13 @@ func TestFSDelete(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test purging a mailbox
|
||||
func TestFSPurge(t *testing.T) {
|
||||
ds, logbuf := setupDataStore()
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "fred"
|
||||
@@ -292,19 +294,19 @@ func TestFSPurge(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test message size calculation
|
||||
func TestFSSize(t *testing.T) {
|
||||
ds, logbuf := setupDataStore()
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "fred"
|
||||
subjects := []string{"a", "br", "much longer than the others"}
|
||||
sentIds := make([]string, len(subjects))
|
||||
sentSizes := make([]int, len(subjects))
|
||||
sentSizes := make([]int64, len(subjects))
|
||||
|
||||
for i, subj := range subjects {
|
||||
// Add a message
|
||||
@@ -330,12 +332,183 @@ func TestFSSize(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test missing files
|
||||
func TestFSMissing(t *testing.T) {
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "fred"
|
||||
subjects := []string{"a", "b", "c"}
|
||||
sentIds := make([]string, len(subjects))
|
||||
|
||||
for i, subj := range subjects {
|
||||
// Add a message
|
||||
id, _ := deliverMessage(ds, mbName, subj, time.Now())
|
||||
sentIds[i] = id
|
||||
}
|
||||
|
||||
mb, err := ds.MailboxFor(mbName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
||||
}
|
||||
|
||||
// Delete a message file without removing it from index
|
||||
msg, err := mb.GetMessage(sentIds[1])
|
||||
assert.Nil(t, err)
|
||||
fmsg := msg.(*FileMessage)
|
||||
_ = os.Remove(fmsg.rawPath())
|
||||
msg, err = mb.GetMessage(sentIds[1])
|
||||
assert.Nil(t, err)
|
||||
|
||||
// Try to read parts of message
|
||||
_, err = msg.ReadHeader()
|
||||
assert.Error(t, err)
|
||||
_, err = msg.ReadBody()
|
||||
assert.Error(t, err)
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test delivering several messages to the same mailbox, see if message cap works
|
||||
func TestFSMessageCap(t *testing.T) {
|
||||
mbCap := 10
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "captain"
|
||||
for i := 0; i < 20; i++ {
|
||||
// Add a message
|
||||
subj := fmt.Sprintf("subject %v", i)
|
||||
deliverMessage(ds, mbName, subj, time.Now())
|
||||
t.Logf("Delivered %q", subj)
|
||||
|
||||
// Check number of messages
|
||||
mb, err := ds.MailboxFor(mbName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
||||
}
|
||||
msgs, err := mb.GetMessages()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
||||
}
|
||||
if len(msgs) > mbCap {
|
||||
t.Errorf("Mailbox should be capped at %v messages, but has %v", mbCap, len(msgs))
|
||||
}
|
||||
|
||||
// Check that the first message is correct
|
||||
first := i - mbCap + 1
|
||||
if first < 0 {
|
||||
first = 0
|
||||
}
|
||||
firstSubj := fmt.Sprintf("subject %v", first)
|
||||
if firstSubj != msgs[0].Subject() {
|
||||
t.Errorf("Expected first subject to be %q, got %q", firstSubj, msgs[0].Subject())
|
||||
}
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test delivering several messages to the same mailbox, see if no message cap works
|
||||
func TestFSNoMessageCap(t *testing.T) {
|
||||
mbCap := 0
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
mbName := "captain"
|
||||
for i := 0; i < 20; i++ {
|
||||
// Add a message
|
||||
subj := fmt.Sprintf("subject %v", i)
|
||||
deliverMessage(ds, mbName, subj, time.Now())
|
||||
t.Logf("Delivered %q", subj)
|
||||
|
||||
// Check number of messages
|
||||
mb, err := ds.MailboxFor(mbName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
|
||||
}
|
||||
msgs, err := mb.GetMessages()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
|
||||
}
|
||||
if len(msgs) != i+1 {
|
||||
t.Errorf("Expected %v messages, got %v", i+1, len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test Get the latest message
|
||||
func TestGetLatestMessage(t *testing.T) {
|
||||
ds, logbuf := setupDataStore(config.DataStoreConfig{})
|
||||
defer teardownDataStore(ds)
|
||||
|
||||
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
|
||||
mbName := "james"
|
||||
|
||||
// Test empty mailbox
|
||||
mb, err := ds.MailboxFor(mbName)
|
||||
assert.Nil(t, err)
|
||||
msg, err := mb.GetMessage("latest")
|
||||
assert.Nil(t, msg)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Deliver test message
|
||||
deliverMessage(ds, mbName, "test", time.Now())
|
||||
|
||||
// Deliver test message 2
|
||||
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
|
||||
|
||||
// Test get the latest message
|
||||
mb, err = ds.MailboxFor(mbName)
|
||||
assert.Nil(t, err)
|
||||
msg, err = mb.GetMessage("latest")
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
|
||||
|
||||
// Deliver test message 3
|
||||
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
|
||||
|
||||
mb, err = ds.MailboxFor(mbName)
|
||||
assert.Nil(t, err)
|
||||
msg, err = mb.GetMessage("latest")
|
||||
assert.Nil(t, err)
|
||||
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
|
||||
|
||||
// Test wrong id
|
||||
_, err = mb.GetMessage("wrongid")
|
||||
assert.Error(t, err)
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// setupDataStore creates a new FileDataStore in a temporary directory
|
||||
func setupDataStore() (*FileDataStore, *bytes.Buffer) {
|
||||
func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
|
||||
path, err := ioutil.TempDir("", "inbucket")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -345,12 +518,14 @@ func setupDataStore() (*FileDataStore, *bytes.Buffer) {
|
||||
buf := new(bytes.Buffer)
|
||||
log.SetOutput(buf)
|
||||
|
||||
return NewFileDataStore(path).(*FileDataStore), buf
|
||||
cfg.Path = path
|
||||
return NewFileDataStore(cfg).(*FileDataStore), buf
|
||||
}
|
||||
|
||||
// deliverMessage creates and delivers a message to the specific mailbox, returning
|
||||
// the size of the generated message.
|
||||
func deliverMessage(ds *FileDataStore, mbName string, subject string, date time.Time) (id string, size int) {
|
||||
func deliverMessage(ds *FileDataStore, mbName string, subject string,
|
||||
date time.Time) (id string, size int64) {
|
||||
// Build fake SMTP message for delivery
|
||||
testMsg := make([]byte, 0, 300)
|
||||
testMsg = append(testMsg, []byte("To: somebody@host\r\n")...)
|
||||
@@ -364,19 +539,22 @@ func deliverMessage(ds *FileDataStore, mbName string, subject string, date time.
|
||||
panic(err)
|
||||
}
|
||||
// Create message object
|
||||
id = generateId(date)
|
||||
msg := &FileMessage{
|
||||
mailbox: mb.(*FileMailbox),
|
||||
writable: true,
|
||||
Fdate: date,
|
||||
Fid: id,
|
||||
id = generateID(date)
|
||||
msg, err := mb.NewMessage()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmsg := msg.(*FileMessage)
|
||||
fmsg.Fdate = date
|
||||
fmsg.Fid = id
|
||||
if err = msg.Append(testMsg); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
msg.Append(testMsg)
|
||||
if err = msg.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return id, len(testMsg)
|
||||
return id, int64(len(testMsg))
|
||||
}
|
||||
|
||||
func teardownDataStore(ds *FileDataStore) {
|
||||
@@ -1,20 +1,27 @@
|
||||
package web
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
// Context is passed into every request handler function
|
||||
type Context struct {
|
||||
Vars map[string]string
|
||||
Session *sessions.Session
|
||||
DataStore smtpd.DataStore
|
||||
IsJson bool
|
||||
DataStore datastore.DataStore
|
||||
MsgHub *msghub.Hub
|
||||
WebConfig config.WebConfig
|
||||
IsJSON bool
|
||||
}
|
||||
|
||||
// Close the Context (currently does nothing)
|
||||
func (c *Context) Close() {
|
||||
// Do nothing
|
||||
}
|
||||
@@ -36,17 +43,26 @@ func headerMatch(req *http.Request, name string, value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NewContext returns a Context for the given HTTP Request
|
||||
func NewContext(req *http.Request) (*Context, error) {
|
||||
vars := mux.Vars(req)
|
||||
sess, err := sessionStore.Get(req, "inbucket")
|
||||
if err != nil {
|
||||
if sess == nil {
|
||||
// No session, must fail
|
||||
return nil, err
|
||||
}
|
||||
// The session cookie was probably signed by an old key, ignore it
|
||||
// gorilla created an empty session for us
|
||||
err = nil
|
||||
}
|
||||
ctx := &Context{
|
||||
Vars: vars,
|
||||
Session: sess,
|
||||
DataStore: DataStore,
|
||||
IsJson: headerMatch(req, "Accept", "application/json"),
|
||||
}
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
MsgHub: msgHub,
|
||||
WebConfig: webConfig,
|
||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||
}
|
||||
return ctx, err
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
package web
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"html"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
// TemplateFuncs declares functions made available to all templates (including partials)
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"friendlyTime": friendlyTime,
|
||||
"reverse": reverse,
|
||||
"textToHtml": textToHtml,
|
||||
"friendlyTime": FriendlyTime,
|
||||
"reverse": Reverse,
|
||||
"textToHtml": TextToHTML,
|
||||
}
|
||||
|
||||
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
||||
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
|
||||
|
||||
// Friendly date & time rendering
|
||||
func friendlyTime(t time.Time) template.HTML {
|
||||
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
|
||||
// otherwise Mon Jan 2, 2006
|
||||
func FriendlyTime(t time.Time) template.HTML {
|
||||
ty, tm, td := t.Date()
|
||||
ny, nm, nd := time.Now().Date()
|
||||
if (ty == ny) && (tm == nm) && (td == nd) {
|
||||
@@ -29,8 +32,8 @@ func friendlyTime(t time.Time) template.HTML {
|
||||
return template.HTML(t.Format("Mon Jan 2, 2006"))
|
||||
}
|
||||
|
||||
// Reversable routing function (shared with templates)
|
||||
func reverse(name string, things ...interface{}) string {
|
||||
// Reverse routing function (shared with templates)
|
||||
func Reverse(name string, things ...interface{}) string {
|
||||
// Convert the things to strings
|
||||
strs := make([]string, len(things))
|
||||
for i, th := range things {
|
||||
@@ -39,23 +42,23 @@ func reverse(name string, things ...interface{}) string {
|
||||
// Grab the route
|
||||
u, err := Router.Get(name).URL(strs...)
|
||||
if err != nil {
|
||||
log.LogError("Failed to reverse route: %v", err)
|
||||
log.Errorf("Failed to reverse route: %v", err)
|
||||
return "/ROUTE-ERROR"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// textToHtml takes plain text, escapes it and tries to pretty it up for
|
||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||
// HTML display
|
||||
func textToHtml(text string) template.HTML {
|
||||
func TextToHTML(text string) template.HTML {
|
||||
text = html.EscapeString(text)
|
||||
text = urlRE.ReplaceAllStringFunc(text, wrapUrl)
|
||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||
return template.HTML(replacer.Replace(text))
|
||||
}
|
||||
|
||||
// wrapUrl wraps a <a href> tag around the provided URL
|
||||
func wrapUrl(url string) string {
|
||||
// WrapURL wraps a <a href> tag around the provided URL
|
||||
func WrapURL(url string) string {
|
||||
unescaped := strings.Replace(url, "&", "&", -1)
|
||||
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
|
||||
}
|
||||
@@ -1,29 +1,30 @@
|
||||
package web
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"html/template"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextToHtml(t *testing.T) {
|
||||
// Identity
|
||||
assert.Equal(t, textToHtml("html"), template.HTML("html"))
|
||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
||||
|
||||
// Check it escapes
|
||||
assert.Equal(t, textToHtml("<html>"), template.HTML("<html>"))
|
||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
||||
|
||||
// Check for linebreaks
|
||||
assert.Equal(t, textToHtml("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, textToHtml("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, textToHtml("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
||||
}
|
||||
|
||||
func TestURLDetection(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
textToHtml("http://google.com/"),
|
||||
TextToHTML("http://google.com/"),
|
||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
||||
assert.Equal(t,
|
||||
textToHtml("http://a.com/?q=a&n=v"),
|
||||
TextToHTML("http://a.com/?q=a&n=v"),
|
||||
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>"))
|
||||
}
|
||||
15
httpd/rest.go
Normal file
15
httpd/rest.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RenderJSON sets the correct HTTP headers for JSON, then writes the specified
|
||||
// data (typically a struct) encoded in JSON
|
||||
func RenderJSON(w http.ResponseWriter, data interface{}) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Expires", "-1")
|
||||
enc := json.NewEncoder(w)
|
||||
return enc.Encode(data)
|
||||
}
|
||||
159
httpd/server.go
Normal file
159
httpd/server.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket
|
||||
type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
var (
|
||||
// DataStore is where all the mailboxes and messages live
|
||||
DataStore datastore.DataStore
|
||||
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
|
||||
// Router is shared between httpd, webui and rest packages. It sends
|
||||
// incoming requests to the correct handler function
|
||||
Router = mux.NewRouter()
|
||||
|
||||
webConfig config.WebConfig
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
sessionStore sessions.Store
|
||||
globalShutdown chan bool
|
||||
|
||||
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
|
||||
ExpWebSocketConnectsCurrent = new(expvar.Int)
|
||||
)
|
||||
|
||||
func init() {
|
||||
m := expvar.NewMap("http")
|
||||
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
|
||||
}
|
||||
|
||||
// Initialize sets up things for unit tests or the Start() method
|
||||
func Initialize(
|
||||
cfg config.WebConfig,
|
||||
shutdownChan chan bool,
|
||||
ds datastore.DataStore,
|
||||
mh *msghub.Hub) {
|
||||
|
||||
webConfig = cfg
|
||||
globalShutdown = shutdownChan
|
||||
|
||||
// NewContext() will use this DataStore for the web handlers
|
||||
DataStore = ds
|
||||
msgHub = mh
|
||||
|
||||
// Content Paths
|
||||
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
|
||||
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
|
||||
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
||||
http.FileServer(http.Dir(cfg.PublicDir))))
|
||||
http.Handle("/", Router)
|
||||
|
||||
// Session cookie setup
|
||||
if cfg.CookieAuthKey == "" {
|
||||
log.Infof("HTTP generating random cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
|
||||
} else {
|
||||
log.Tracef("HTTP using configured cookie.auth.key")
|
||||
sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey))
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start(ctx context.Context) {
|
||||
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
|
||||
server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: nil,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// We don't use ListenAndServe because it lacks a way to close the listener
|
||||
log.Infof("HTTP listening on TCP4 %v", addr)
|
||||
var err error
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
|
||||
emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
log.Tracef("HTTP server shutting down on request")
|
||||
}
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit
|
||||
if err := listener.Close(); err != nil {
|
||||
log.Errorf("Failed to close HTTP listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serve begins serving HTTP requests
|
||||
func serve(ctx context.Context) {
|
||||
// server.Serve blocks until we close the listener
|
||||
err := server.Serve(listener)
|
||||
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
// Nop
|
||||
default:
|
||||
log.Errorf("HTTP server failed: %v", err)
|
||||
emergencyShutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP builds the context and passes onto the real handler
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Create the context
|
||||
ctx, err := NewContext(req)
|
||||
if err != nil {
|
||||
log.Errorf("HTTP failed to create context: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it
|
||||
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
|
||||
err = h(w, req, ctx)
|
||||
if err != nil {
|
||||
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
default:
|
||||
close(globalShutdown)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package web
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
var cachedMutex sync.Mutex
|
||||
@@ -20,7 +20,7 @@ var cachedPartials = map[string]*template.Template{}
|
||||
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, false)
|
||||
if err != nil {
|
||||
log.LogError("Error in template '%v': %v", name, err)
|
||||
log.Errorf("Error in template '%v': %v", name, err)
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
@@ -32,7 +32,7 @@ func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error
|
||||
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
|
||||
t, err := ParseTemplate(name, true)
|
||||
if err != nil {
|
||||
log.LogError("Error in template '%v': %v", name, err)
|
||||
log.Errorf("Error in template '%v': %v", name, err)
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Expires", "-1")
|
||||
@@ -49,10 +49,9 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
cfg := config.GetWebConfig()
|
||||
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
|
||||
tempFile := filepath.Join(cfg.TemplateDir, tempPath)
|
||||
log.LogTrace("Parsing template %v", tempFile)
|
||||
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
|
||||
log.Tracef("Parsing template %v", tempFile)
|
||||
|
||||
var err error
|
||||
var t *template.Template
|
||||
@@ -63,19 +62,19 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
|
||||
t, err = t.ParseFiles(tempFile)
|
||||
} else {
|
||||
t = template.New("_base.html").Funcs(TemplateFuncs)
|
||||
t, err = t.ParseFiles(filepath.Join(cfg.TemplateDir, "_base.html"), tempFile)
|
||||
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Allows us to disable caching for theme development
|
||||
if cfg.TemplateCache {
|
||||
if webConfig.TemplateCache {
|
||||
if partial {
|
||||
log.LogTrace("Caching partial %v", name)
|
||||
log.Tracef("Caching partial %v", name)
|
||||
cachedTemplates[name] = t
|
||||
} else {
|
||||
log.LogTrace("Caching template %v", name)
|
||||
log.Tracef("Caching template %v", name)
|
||||
cachedTemplates[name] = t
|
||||
}
|
||||
}
|
||||
231
inbucket.go
231
inbucket.go
@@ -1,45 +1,79 @@
|
||||
/*
|
||||
This is the inbucket daemon launcher
|
||||
*/
|
||||
// main is the inbucket daemon launcher
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/pop3d"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"github.com/jhillyerd/inbucket/web"
|
||||
golog "log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/filestore"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/pop3d"
|
||||
"github.com/jhillyerd/inbucket/rest"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"github.com/jhillyerd/inbucket/webui"
|
||||
)
|
||||
|
||||
// Command line flags
|
||||
var help = flag.Bool("help", false, "Displays this help")
|
||||
var pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
|
||||
var logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
|
||||
var (
|
||||
// version contains the build version number, populated during linking
|
||||
version = "undefined"
|
||||
|
||||
// startTime is used to calculate uptime of Inbucket
|
||||
var startTime = time.Now()
|
||||
// date contains the build date, populated during linking
|
||||
date = "undefined"
|
||||
|
||||
// The file we send log output to, will be nil for stderr or stdout
|
||||
var logf *os.File
|
||||
// Command line flags
|
||||
help = flag.Bool("help", false, "Displays this help")
|
||||
pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
|
||||
logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
|
||||
|
||||
var smtpServer *smtpd.Server
|
||||
var pop3Server *pop3d.Server
|
||||
// shutdownChan - close it to tell Inbucket to shut down cleanly
|
||||
shutdownChan = make(chan bool)
|
||||
|
||||
// Server instances
|
||||
smtpServer *smtpd.Server
|
||||
pop3Server *pop3d.Server
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
// Server uptime for status page
|
||||
startTime := time.Now()
|
||||
expvar.Publish("uptime", expvar.Func(func() interface{} {
|
||||
return time.Since(startTime) / time.Second
|
||||
}))
|
||||
|
||||
// Goroutine count for status page
|
||||
expvar.Publish("goroutines", expvar.Func(func() interface{} {
|
||||
return runtime.NumGoroutine()
|
||||
}))
|
||||
}
|
||||
|
||||
func main() {
|
||||
config.Version = version
|
||||
config.BuildDate = date
|
||||
|
||||
flag.Parse()
|
||||
if *help {
|
||||
flag.Usage()
|
||||
return
|
||||
}
|
||||
|
||||
// Root context
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
|
||||
// Load & Parse config
|
||||
if flag.NArg() != 1 {
|
||||
flag.Usage()
|
||||
@@ -52,131 +86,98 @@ func main() {
|
||||
}
|
||||
|
||||
// Setup signal handler
|
||||
sigChan := make(chan os.Signal)
|
||||
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM)
|
||||
go signalProcessor(sigChan)
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
// Configure logging, close std* fds
|
||||
level, _ := config.Config.String("logging", "level")
|
||||
log.SetLogLevel(level)
|
||||
|
||||
if *logfile != "stderr" {
|
||||
// stderr is the go logging default
|
||||
if *logfile == "stdout" {
|
||||
// set to stdout
|
||||
golog.SetOutput(os.Stdout)
|
||||
} else {
|
||||
err = openLogFile()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer closeLogFile()
|
||||
|
||||
// close std* streams
|
||||
os.Stdout.Close()
|
||||
os.Stderr.Close() // Warning: this will hide panic() output
|
||||
os.Stdin.Close()
|
||||
os.Stdout = logf
|
||||
os.Stderr = logf
|
||||
}
|
||||
// Initialize logging
|
||||
log.SetLogLevel(config.GetLogLevel())
|
||||
if err := log.Initialize(*logfile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer log.Close()
|
||||
|
||||
log.Infof("Inbucket %v (%v) starting...", config.Version, config.BuildDate)
|
||||
|
||||
// Write pidfile if requested
|
||||
// TODO: Probably supposed to remove pidfile during shutdown
|
||||
if *pidfile != "none" {
|
||||
pidf, err := os.Create(*pidfile)
|
||||
if err != nil {
|
||||
log.LogError("Failed to create %v: %v", *pidfile, err)
|
||||
log.Errorf("Failed to create %q: %v", *pidfile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pidf.Close()
|
||||
fmt.Fprintf(pidf, "%v\n", os.Getpid())
|
||||
if err := pidf.Close(); err != nil {
|
||||
log.Errorf("Failed to close PID file %q: %v", *pidfile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create message hub
|
||||
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
|
||||
|
||||
// Grab our datastore
|
||||
ds := smtpd.DefaultFileDataStore()
|
||||
ds := filestore.DefaultFileDataStore()
|
||||
|
||||
// Start HTTP server
|
||||
go web.Start()
|
||||
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
|
||||
webui.SetupRoutes(httpd.Router)
|
||||
rest.SetupRoutes(httpd.Router)
|
||||
go httpd.Start(rootCtx)
|
||||
|
||||
// Start POP3 server
|
||||
pop3Server = pop3d.New()
|
||||
go pop3Server.Start()
|
||||
pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Startup SMTP server, block until it exits
|
||||
smtpServer = smtpd.NewSmtpServer(config.GetSmtpConfig(), ds)
|
||||
smtpServer.Start()
|
||||
// Startup SMTP server
|
||||
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel
|
||||
signalLoop:
|
||||
for {
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
switch sig {
|
||||
case syscall.SIGHUP:
|
||||
log.Infof("Recieved SIGHUP, cycling logfile")
|
||||
log.Rotate()
|
||||
case syscall.SIGINT:
|
||||
// Shutdown requested
|
||||
log.Infof("Received SIGINT, shutting down")
|
||||
close(shutdownChan)
|
||||
case syscall.SIGTERM:
|
||||
// Shutdown requested
|
||||
log.Infof("Received SIGTERM, shutting down")
|
||||
close(shutdownChan)
|
||||
}
|
||||
case <-shutdownChan:
|
||||
rootCancel()
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for active connections to finish
|
||||
go timedExit()
|
||||
smtpServer.Drain()
|
||||
pop3Server.Drain()
|
||||
|
||||
removePIDFile()
|
||||
}
|
||||
|
||||
// openLogFile creates or appends to the logfile passed on commandline
|
||||
func openLogFile() error {
|
||||
// use specified log file
|
||||
var err error
|
||||
logf, err = os.OpenFile(*logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create %v: %v\n", *logfile, err)
|
||||
}
|
||||
golog.SetOutput(logf)
|
||||
log.LogTrace("Opened new logfile")
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeLogFile closes the current logfile
|
||||
func closeLogFile() error {
|
||||
log.LogTrace("Closing logfile")
|
||||
return logf.Close()
|
||||
}
|
||||
|
||||
// signalProcessor is a goroutine that handles OS signals
|
||||
func signalProcessor(c <-chan os.Signal) {
|
||||
for {
|
||||
sig := <-c
|
||||
switch sig {
|
||||
case syscall.SIGHUP:
|
||||
// Rotate logs if configured
|
||||
if logf != nil {
|
||||
log.LogInfo("Recieved SIGHUP, cycling logfile")
|
||||
closeLogFile()
|
||||
openLogFile()
|
||||
} else {
|
||||
log.LogInfo("Ignoring SIGHUP, logfile not configured")
|
||||
}
|
||||
case syscall.SIGTERM:
|
||||
// Initiate shutdown
|
||||
log.LogInfo("Received SIGTERM, shutting down")
|
||||
go timedExit()
|
||||
web.Stop()
|
||||
if smtpServer != nil {
|
||||
smtpServer.Stop()
|
||||
} else {
|
||||
log.LogError("smtpServer was nil during shutdown")
|
||||
}
|
||||
// removePIDFile removes the PID file if created
|
||||
func removePIDFile() {
|
||||
if *pidfile != "none" {
|
||||
if err := os.Remove(*pidfile); err != nil {
|
||||
log.Errorf("Failed to remove %q: %v", *pidfile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// timedExit is called as a goroutine during shutdown, it will force an exit after 15 seconds
|
||||
// timedExit is called as a goroutine during shutdown, it will force an exit
|
||||
// after 15 seconds
|
||||
func timedExit() {
|
||||
time.Sleep(15 * time.Second)
|
||||
log.LogError("Inbucket clean shutdown timed out, forcing exit")
|
||||
log.Errorf("Clean shutdown took too long, forcing exit")
|
||||
removePIDFile()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func init() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
expvar.Publish("uptime", expvar.Func(uptime))
|
||||
}
|
||||
|
||||
// uptime() is published as an expvar
|
||||
func uptime() interface{} {
|
||||
return time.Since(startTime) / time.Second
|
||||
}
|
||||
|
||||
130
log/logging.go
130
log/logging.go
@@ -1,65 +1,145 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
golog "log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LogLevel int
|
||||
// Level is used to indicate the severity of a log entry
|
||||
type Level int
|
||||
|
||||
const (
|
||||
ERROR LogLevel = iota
|
||||
// ERROR indicates a significant problem was encountered
|
||||
ERROR Level = iota
|
||||
// WARN indicates something that may be a problem
|
||||
WARN
|
||||
// INFO indicates a purely informational log entry
|
||||
INFO
|
||||
// TRACE entries are meant for development purposes only
|
||||
TRACE
|
||||
)
|
||||
|
||||
var MaxLogLevel LogLevel = TRACE
|
||||
var (
|
||||
// MaxLevel is the highest Level we will log (max TRACE, min ERROR)
|
||||
MaxLevel = TRACE
|
||||
|
||||
// SetLogLevel sets MaxLogLevel based on the provided string
|
||||
// logfname is the name of the logfile
|
||||
logfname string
|
||||
|
||||
// logf is the file we send log output to, will be nil for stderr or stdout
|
||||
logf *os.File
|
||||
)
|
||||
|
||||
// Initialize logging. If logfile is equal to "stderr" or "stdout", then
|
||||
// we will log to that output stream. Otherwise the specificed file will
|
||||
// opened for writing, and all log data will be placed in it.
|
||||
func Initialize(logfile string) error {
|
||||
if logfile != "stderr" {
|
||||
// stderr is the go logging default
|
||||
if logfile == "stdout" {
|
||||
// set to stdout
|
||||
golog.SetOutput(os.Stdout)
|
||||
} else {
|
||||
logfname = logfile
|
||||
if err := openLogFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Platform specific
|
||||
closeStdin()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLogLevel sets MaxLevel based on the provided string
|
||||
func SetLogLevel(level string) (ok bool) {
|
||||
switch strings.ToUpper(level) {
|
||||
case "ERROR":
|
||||
MaxLogLevel = ERROR
|
||||
MaxLevel = ERROR
|
||||
case "WARN":
|
||||
MaxLogLevel = WARN
|
||||
MaxLevel = WARN
|
||||
case "INFO":
|
||||
MaxLogLevel = INFO
|
||||
MaxLevel = INFO
|
||||
case "TRACE":
|
||||
MaxLogLevel = TRACE
|
||||
MaxLevel = TRACE
|
||||
default:
|
||||
LogError("Unknown log level requested: %v", level)
|
||||
Errorf("Unknown log level requested: " + level)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Error logs a message to the 'standard' Logger (always)
|
||||
func LogError(msg string, args ...interface{}) {
|
||||
// Errorf logs a message to the 'standard' Logger (always), accepts format strings
|
||||
func Errorf(msg string, args ...interface{}) {
|
||||
msg = "[ERROR] " + msg
|
||||
log.Printf(msg, args...)
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
|
||||
// Warn logs a message to the 'standard' Logger if MaxLogLevel is >= WARN
|
||||
func LogWarn(msg string, args ...interface{}) {
|
||||
if MaxLogLevel >= WARN {
|
||||
// Warnf logs a message to the 'standard' Logger if MaxLevel is >= WARN, accepts format strings
|
||||
func Warnf(msg string, args ...interface{}) {
|
||||
if MaxLevel >= WARN {
|
||||
msg = "[WARN ] " + msg
|
||||
log.Printf(msg, args...)
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Info logs a message to the 'standard' Logger if MaxLogLevel is >= INFO
|
||||
func LogInfo(msg string, args ...interface{}) {
|
||||
if MaxLogLevel >= INFO {
|
||||
// Infof logs a message to the 'standard' Logger if MaxLevel is >= INFO, accepts format strings
|
||||
func Infof(msg string, args ...interface{}) {
|
||||
if MaxLevel >= INFO {
|
||||
msg = "[INFO ] " + msg
|
||||
log.Printf(msg, args...)
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Trace logs a message to the 'standard' Logger if MaxLogLevel is >= TRACE
|
||||
func LogTrace(msg string, args ...interface{}) {
|
||||
if MaxLogLevel >= TRACE {
|
||||
// Tracef logs a message to the 'standard' Logger if MaxLevel is >= TRACE, accepts format strings
|
||||
func Tracef(msg string, args ...interface{}) {
|
||||
if MaxLevel >= TRACE {
|
||||
msg = "[TRACE] " + msg
|
||||
log.Printf(msg, args...)
|
||||
golog.Printf(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate closes the current log file, then reopens it. This gives an external
|
||||
// log rotation system the opportunity to move the existing log file out of the
|
||||
// way and have Inbucket create a new one.
|
||||
func Rotate() {
|
||||
// Rotate logs if configured
|
||||
if logf != nil {
|
||||
closeLogFile()
|
||||
// There is nothing we can do if the log open fails
|
||||
_ = openLogFile()
|
||||
} else {
|
||||
Infof("Ignoring SIGHUP, logfile not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// Close the log file if we have one open
|
||||
func Close() {
|
||||
if logf != nil {
|
||||
closeLogFile()
|
||||
}
|
||||
}
|
||||
|
||||
// openLogFile creates or appends to the logfile passed on commandline
|
||||
func openLogFile() error {
|
||||
// use specified log file
|
||||
var err error
|
||||
logf, err = os.OpenFile(logfname, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create %v: %v\n", logfname, err)
|
||||
}
|
||||
golog.SetOutput(logf)
|
||||
Tracef("Opened new logfile")
|
||||
// Platform specific
|
||||
reassignStdout()
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeLogFile closes the current logfile
|
||||
func closeLogFile() {
|
||||
Tracef("Closing logfile")
|
||||
// We are never in a situation where we can do anything about failing to close
|
||||
_ = logf.Close()
|
||||
}
|
||||
|
||||
62
log/metrics.go
Normal file
62
log/metrics.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"expvar"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TickerFunc is the type of metrics function accepted by AddTickerFunc
|
||||
type TickerFunc func()
|
||||
|
||||
var tickerFuncChan = make(chan TickerFunc)
|
||||
|
||||
func init() {
|
||||
go metricsTicker()
|
||||
}
|
||||
|
||||
// AddTickerFunc adds a new function callback to the list of metrics TickerFuncs that get
|
||||
// called each minute.
|
||||
func AddTickerFunc(f TickerFunc) {
|
||||
tickerFuncChan <- f
|
||||
}
|
||||
|
||||
// PushMetric adds the metric to the end of the list and returns a comma separated string of the
|
||||
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
|
||||
// tracks deltas between these values - there is nothing to compare the first value against.
|
||||
func PushMetric(history *list.List, ev expvar.Var) string {
|
||||
history.PushBack(ev.String())
|
||||
if history.Len() > 61 {
|
||||
history.Remove(history.Front())
|
||||
}
|
||||
return joinStringList(history)
|
||||
}
|
||||
|
||||
// joinStringList joins a List containing strings by commas
|
||||
func joinStringList(listOfStrings *list.List) string {
|
||||
if listOfStrings.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
s := make([]string, 0, listOfStrings.Len())
|
||||
for e := listOfStrings.Front(); e != nil; e = e.Next() {
|
||||
s = append(s, e.Value.(string))
|
||||
}
|
||||
return strings.Join(s, ",")
|
||||
}
|
||||
|
||||
func metricsTicker() {
|
||||
funcs := make([]TickerFunc, 0)
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
for _, f := range funcs {
|
||||
f()
|
||||
}
|
||||
case f := <-tickerFuncChan:
|
||||
funcs = append(funcs, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
log/stdout_unix.go
Normal file
31
log/stdout_unix.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// +build !windows
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
"os"
|
||||
)
|
||||
|
||||
// closeStdin will close stdin on Unix platforms - this is standard practice
|
||||
// for daemons
|
||||
func closeStdin() {
|
||||
if err := os.Stdin.Close(); err != nil {
|
||||
// Not a fatal error
|
||||
Errorf("Failed to close os.Stdin during log setup")
|
||||
}
|
||||
}
|
||||
|
||||
// reassignStdout points stdout/stderr to our logfile on systems that support
|
||||
// the Dup2 syscall per https://github.com/golang/go/issues/325
|
||||
func reassignStdout() {
|
||||
Tracef("Unix reassignStdout()")
|
||||
if err := unix.Dup2(int(logf.Fd()), 1); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to re-assign stdout to logfile: %v", err)
|
||||
}
|
||||
if err := unix.Dup2(int(logf.Fd()), 2); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to re-assign stderr to logfile: %v", err)
|
||||
}
|
||||
}
|
||||
37
log/stdout_windows.go
Normal file
37
log/stdout_windows.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// +build windows
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var stdOutsClosed = false
|
||||
|
||||
// closeStdin does nothing on Windows, it would always fail
|
||||
func closeStdin() {
|
||||
// Nop
|
||||
}
|
||||
|
||||
// reassignStdout points stdout/stderr to our logfile on systems that do not
|
||||
// support the Dup2 syscall
|
||||
func reassignStdout() {
|
||||
Tracef("Windows reassignStdout()")
|
||||
if !stdOutsClosed {
|
||||
// Close std* streams to prevent accidental output, they will be redirected to
|
||||
// our logfile below
|
||||
|
||||
// Warning: this will hide panic() output, sorry Windows users
|
||||
if err := os.Stderr.Close(); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to close os.Stderr during log setup")
|
||||
}
|
||||
if err := os.Stdin.Close(); err != nil {
|
||||
// Not considered fatal
|
||||
Errorf("Failed to close os.Stdin during log setup")
|
||||
}
|
||||
os.Stdout = logf
|
||||
os.Stderr = logf
|
||||
stdOutsClosed = true
|
||||
}
|
||||
}
|
||||
110
msghub/hub.go
Normal file
110
msghub/hub.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package msghub
|
||||
|
||||
import (
|
||||
"container/ring"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Length of msghub operation queue
|
||||
const opChanLen = 100
|
||||
|
||||
// Message contains the basic header data for a message
|
||||
type Message struct {
|
||||
Mailbox string
|
||||
ID string
|
||||
From string
|
||||
To []string
|
||||
Subject string
|
||||
Date time.Time
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Listener receives the contents of the history buffer, followed by new messages
|
||||
type Listener interface {
|
||||
Receive(msg Message) error
|
||||
}
|
||||
|
||||
// Hub relays messages on to its listeners
|
||||
type Hub struct {
|
||||
// history buffer, points next Message to write. Proceeding non-nil entry is oldest Message
|
||||
history *ring.Ring
|
||||
listeners map[Listener]struct{} // listeners interested in new messages
|
||||
opChan chan func(h *Hub) // operations queued for this actor
|
||||
}
|
||||
|
||||
// New constructs a new Hub which will cache historyLen messages in memory for playback to future
|
||||
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
|
||||
// context is canceled.
|
||||
func New(ctx context.Context, historyLen int) *Hub {
|
||||
h := &Hub{
|
||||
history: ring.New(historyLen),
|
||||
listeners: make(map[Listener]struct{}),
|
||||
opChan: make(chan func(h *Hub), opChanLen),
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Shutdown
|
||||
close(h.opChan)
|
||||
return
|
||||
case op := <-h.opChan:
|
||||
op(h)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
|
||||
// history buffer and then relayed to all registered listeners.
|
||||
func (hub *Hub) Dispatch(msg Message) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
if h.history != nil {
|
||||
// Add to history buffer
|
||||
h.history.Value = msg
|
||||
h.history = h.history.Next()
|
||||
// Deliver message to all listeners, removing listeners if they return an error
|
||||
for l := range h.listeners {
|
||||
if err := l.Receive(msg); err != nil {
|
||||
delete(h.listeners, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddListener registers a listener to receive broadcasted messages.
|
||||
func (hub *Hub) AddListener(l Listener) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
// Playback log
|
||||
h.history.Do(func(v interface{}) {
|
||||
if v != nil {
|
||||
l.Receive(v.(Message))
|
||||
}
|
||||
})
|
||||
|
||||
// Add to listeners
|
||||
h.listeners[l] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveListener deletes a listener registration, it will cease to receive messages.
|
||||
func (hub *Hub) RemoveListener(l Listener) {
|
||||
hub.opChan <- func(h *Hub) {
|
||||
delete(h.listeners, l)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync blocks until the msghub has processed its queue up to this point, useful
|
||||
// for unit tests.
|
||||
func (hub *Hub) Sync() {
|
||||
done := make(chan struct{})
|
||||
hub.opChan <- func(h *Hub) {
|
||||
close(done)
|
||||
}
|
||||
<-done
|
||||
}
|
||||
255
msghub/hub_test.go
Normal file
255
msghub/hub_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package msghub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testListener implements the Listener interface, mock for unit tests
|
||||
type testListener struct {
|
||||
messages []*Message // received messages
|
||||
wantMessages int // how many messages this listener wants to receive
|
||||
errorAfter int // when != 0, messages until Receive() begins returning error
|
||||
|
||||
done chan struct{} // closed once we have received wantMessages
|
||||
overflow chan struct{} // closed if we receive wantMessages+1
|
||||
}
|
||||
|
||||
func newTestListener(want int) *testListener {
|
||||
l := &testListener{
|
||||
messages: make([]*Message, 0, want*2),
|
||||
wantMessages: want,
|
||||
done: make(chan struct{}),
|
||||
overflow: make(chan struct{}),
|
||||
}
|
||||
if want == 0 {
|
||||
close(l.done)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
|
||||
// if instructed
|
||||
func (l *testListener) Receive(msg Message) error {
|
||||
l.messages = append(l.messages, &msg)
|
||||
if len(l.messages) == l.wantMessages {
|
||||
close(l.done)
|
||||
}
|
||||
if len(l.messages) == l.wantMessages+1 {
|
||||
close(l.overflow)
|
||||
}
|
||||
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
|
||||
return fmt.Errorf("Too many messages")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String formats the got vs wanted message counts
|
||||
func (l *testListener) String() string {
|
||||
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
|
||||
}
|
||||
|
||||
func TestHubNew(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
if hub == nil {
|
||||
t.Fatal("New() == nil, expected a new Hub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubZeroLen(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 0)
|
||||
m := Message{}
|
||||
for i := 0; i < 100; i++ {
|
||||
hub.Dispatch(m)
|
||||
}
|
||||
// Just making sure Hub doesn't panic
|
||||
}
|
||||
|
||||
func TestHubZeroListeners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
for i := 0; i < 100; i++ {
|
||||
hub.Dispatch(m)
|
||||
}
|
||||
// Just making sure Hub doesn't panic
|
||||
}
|
||||
|
||||
func TestHubOneListener(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
l := newTestListener(1)
|
||||
|
||||
hub.AddListener(l)
|
||||
hub.Dispatch(m)
|
||||
|
||||
// Wait for messages
|
||||
select {
|
||||
case <-l.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("Timeout:", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRemoveListener(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
l := newTestListener(1)
|
||||
|
||||
hub.AddListener(l)
|
||||
hub.Dispatch(m)
|
||||
hub.RemoveListener(l)
|
||||
hub.Dispatch(m)
|
||||
hub.Sync()
|
||||
|
||||
// Wait for messages
|
||||
select {
|
||||
case <-l.overflow:
|
||||
t.Error(l)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected result, no overflow
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRemoveListenerOnError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
|
||||
// error after 1 means listener should receive 2 messages before being removed
|
||||
l := newTestListener(2)
|
||||
l.errorAfter = 1
|
||||
|
||||
hub.AddListener(l)
|
||||
hub.Dispatch(m)
|
||||
hub.Dispatch(m)
|
||||
hub.Dispatch(m)
|
||||
hub.Dispatch(m)
|
||||
hub.Sync()
|
||||
|
||||
// Wait for messages
|
||||
select {
|
||||
case <-l.overflow:
|
||||
t.Error(l)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected result, no overflow
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHistoryReplay(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 100)
|
||||
l1 := newTestListener(3)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast 3 messages with no listeners
|
||||
msgs := make([]Message, 3)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = Message{
|
||||
Subject: fmt.Sprintf("subj %v", i),
|
||||
}
|
||||
hub.Dispatch(msgs[i])
|
||||
}
|
||||
|
||||
// Wait for messages (live)
|
||||
select {
|
||||
case <-l1.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout:", l1)
|
||||
}
|
||||
|
||||
// Add a new listener
|
||||
l2 := newTestListener(3)
|
||||
hub.AddListener(l2)
|
||||
|
||||
// Wait for messages (history)
|
||||
select {
|
||||
case <-l2.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout:", l2)
|
||||
}
|
||||
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
got := l2.messages[i].Subject
|
||||
want := msgs[i].Subject
|
||||
if got != want {
|
||||
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubHistoryReplayWrap(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
hub := New(ctx, 5)
|
||||
l1 := newTestListener(20)
|
||||
hub.AddListener(l1)
|
||||
|
||||
// Broadcast more messages than the hub can hold
|
||||
msgs := make([]Message, 20)
|
||||
for i := 0; i < len(msgs); i++ {
|
||||
msgs[i] = Message{
|
||||
Subject: fmt.Sprintf("subj %v", i),
|
||||
}
|
||||
hub.Dispatch(msgs[i])
|
||||
}
|
||||
|
||||
// Wait for messages (live)
|
||||
select {
|
||||
case <-l1.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout:", l1)
|
||||
}
|
||||
|
||||
// Add a new listener
|
||||
l2 := newTestListener(5)
|
||||
hub.AddListener(l2)
|
||||
|
||||
// Wait for messages (history)
|
||||
select {
|
||||
case <-l2.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout:", l2)
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
got := l2.messages[i].Subject
|
||||
want := msgs[i+15].Subject
|
||||
if got != want {
|
||||
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
hub := New(ctx, 5)
|
||||
m := Message{}
|
||||
l := newTestListener(1)
|
||||
|
||||
hub.AddListener(l)
|
||||
hub.Dispatch(m)
|
||||
hub.Sync()
|
||||
cancel()
|
||||
|
||||
// Wait for messages
|
||||
select {
|
||||
case <-l.overflow:
|
||||
t.Error(l)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected result, no overflow
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,26 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
// State tracks the current mode of our POP3 state machine
|
||||
type State int
|
||||
|
||||
const (
|
||||
AUTHORIZATION State = iota // The client must now identify and authenticate
|
||||
TRANSACTION // Mailbox open, client may now issue commands
|
||||
// AUTHORIZATION state: the client must now identify and authenticate
|
||||
AUTHORIZATION State = iota
|
||||
// TRANSACTION state: mailbox open, client may now issue commands
|
||||
TRANSACTION
|
||||
// QUIT state: client requests us to end session
|
||||
QUIT
|
||||
)
|
||||
|
||||
@@ -50,21 +55,23 @@ var commands = map[string]bool{
|
||||
"CAPA": true,
|
||||
}
|
||||
|
||||
// Session defines an active POP3 session
|
||||
type Session struct {
|
||||
server *Server // Reference to the server we belong to
|
||||
id int // Session ID number
|
||||
conn net.Conn // Our network connection
|
||||
remoteHost string // IP address of client
|
||||
sendError error // Used to bail out of read loop on send error
|
||||
state State // Current session state
|
||||
reader *bufio.Reader // Buffered reader for our net conn
|
||||
user string // Mailbox name
|
||||
mailbox smtpd.Mailbox // Mailbox instance
|
||||
messages []smtpd.Message // Slice of messages in mailbox
|
||||
retain []bool // Messages to retain upon UPDATE (true=retain)
|
||||
msgCount int // Number of undeleted messages
|
||||
server *Server // Reference to the server we belong to
|
||||
id int // Session ID number
|
||||
conn net.Conn // Our network connection
|
||||
remoteHost string // IP address of client
|
||||
sendError error // Used to bail out of read loop on send error
|
||||
state State // Current session state
|
||||
reader *bufio.Reader // Buffered reader for our net conn
|
||||
user string // Mailbox name
|
||||
mailbox datastore.Mailbox // Mailbox instance
|
||||
messages []datastore.Message // Slice of messages in mailbox
|
||||
retain []bool // Messages to retain upon UPDATE (true=retain)
|
||||
msgCount int // Number of undeleted messages
|
||||
}
|
||||
|
||||
// NewSession creates a new POP3 session
|
||||
func NewSession(server *Server, id int, conn net.Conn) *Session {
|
||||
reader := bufio.NewReader(conn)
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
@@ -84,10 +91,12 @@ func (ses *Session) String() string {
|
||||
* 5. Goto 2
|
||||
*/
|
||||
func (s *Server) startSession(id int, conn net.Conn) {
|
||||
log.LogInfo("POP3 connection from %v, starting session <%v>", conn.RemoteAddr(), id)
|
||||
log.Infof("POP3 connection from %v, starting session <%v>", conn.RemoteAddr(), id)
|
||||
//expConnectsCurrent.Add(1)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Errorf("Error closing POP3 connection for <%v>: %v", id, err)
|
||||
}
|
||||
s.waitgroup.Done()
|
||||
//expConnectsCurrent.Add(-1)
|
||||
}()
|
||||
@@ -234,7 +243,7 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
|
||||
var size int64
|
||||
for i, msg := range ses.messages {
|
||||
if ses.retain[i] {
|
||||
count += 1
|
||||
count++
|
||||
size += msg.Size()
|
||||
}
|
||||
}
|
||||
@@ -305,12 +314,12 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
|
||||
ses.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
|
||||
return
|
||||
}
|
||||
ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].Id()))
|
||||
ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].ID()))
|
||||
} else {
|
||||
ses.send(fmt.Sprintf("+OK Listing %v messages", ses.msgCount))
|
||||
for i, msg := range ses.messages {
|
||||
if ses.retain[i] {
|
||||
ses.send(fmt.Sprintf("%v %v", i+1, msg.Id()))
|
||||
ses.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
|
||||
}
|
||||
}
|
||||
ses.send(".")
|
||||
@@ -339,7 +348,7 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
|
||||
}
|
||||
if ses.retain[msgNum-1] {
|
||||
ses.retain[msgNum-1] = false
|
||||
ses.msgCount -= 1
|
||||
ses.msgCount--
|
||||
ses.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
|
||||
} else {
|
||||
ses.logWarn("Client tried to DELE an already deleted message")
|
||||
@@ -423,14 +432,19 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
|
||||
}
|
||||
|
||||
// Send the contents of the message to the client
|
||||
func (ses *Session) sendMessage(msg smtpd.Message) {
|
||||
func (ses *Session) sendMessage(msg datastore.Message) {
|
||||
reader, err := msg.RawReader()
|
||||
defer reader.Close()
|
||||
if err != nil {
|
||||
ses.logError("Failed to read message for RETR command")
|
||||
ses.send("-ERR Failed to RETR that message, internal error")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
ses.logError("Failed to close message: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
@@ -451,14 +465,19 @@ func (ses *Session) sendMessage(msg smtpd.Message) {
|
||||
}
|
||||
|
||||
// Send the headers plus the top N lines to the client
|
||||
func (ses *Session) sendMessageTop(msg smtpd.Message, lineCount int) {
|
||||
func (ses *Session) sendMessageTop(msg datastore.Message, lineCount int) {
|
||||
reader, err := msg.RawReader()
|
||||
defer reader.Close()
|
||||
if err != nil {
|
||||
ses.logError("Failed to read message for RETR command")
|
||||
ses.send("-ERR Failed to RETR that message, internal error")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
ses.logError("Failed to close message: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
inBody := false
|
||||
for scanner.Scan() {
|
||||
@@ -472,7 +491,7 @@ func (ses *Session) sendMessageTop(msg smtpd.Message, lineCount int) {
|
||||
if lineCount < 1 {
|
||||
break
|
||||
} else {
|
||||
lineCount -= 1
|
||||
lineCount--
|
||||
}
|
||||
} else {
|
||||
if line == "" {
|
||||
@@ -506,7 +525,7 @@ func (ses *Session) loadMailbox() {
|
||||
// Reset retain flag to true for all messages
|
||||
func (ses *Session) retainAll() {
|
||||
ses.retain = make([]bool, len(ses.messages))
|
||||
for i, _ := range ses.retain {
|
||||
for i := range ses.retain {
|
||||
ses.retain[i] = true
|
||||
}
|
||||
ses.msgCount = len(ses.messages)
|
||||
@@ -521,7 +540,9 @@ func (ses *Session) processDeletes() {
|
||||
for i, msg := range ses.messages {
|
||||
if !ses.retain[i] {
|
||||
ses.logTrace("Deleting %v", msg)
|
||||
msg.Delete()
|
||||
if err := msg.Delete(); err != nil {
|
||||
ses.logWarn("Error deleting %v: %v", msg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,13 +582,17 @@ func (ses *Session) readByteLine(buf *bytes.Buffer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(line)
|
||||
if _, err = buf.Write(line); err != nil {
|
||||
return err
|
||||
}
|
||||
// Read the next byte looking for '\n'
|
||||
c, err := ses.reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.WriteByte(c)
|
||||
if err := buf.WriteByte(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if c == '\n' {
|
||||
// We've reached the end of the line, return
|
||||
return nil
|
||||
@@ -611,21 +636,21 @@ func (ses *Session) ooSeq(cmd string) {
|
||||
|
||||
// Session specific logging methods
|
||||
func (ses *Session) logTrace(msg string, args ...interface{}) {
|
||||
log.LogTrace("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
log.Tracef("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (ses *Session) logInfo(msg string, args ...interface{}) {
|
||||
log.LogInfo("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
log.Infof("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (ses *Session) logWarn(msg string, args ...interface{}) {
|
||||
// Update metrics
|
||||
//expWarnsTotal.Add(1)
|
||||
log.LogWarn("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
log.Warnf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (ses *Session) logError(msg string, args ...interface{}) {
|
||||
// Update metrics
|
||||
//expErrorsTotal.Add(1)
|
||||
log.LogError("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
log.Errorf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
@@ -1,53 +1,74 @@
|
||||
package pop3d
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
// Real server code starts here
|
||||
// Server defines an instance of our POP3 server
|
||||
type Server struct {
|
||||
host string
|
||||
domain string
|
||||
maxIdleSeconds int
|
||||
dataStore smtpd.DataStore
|
||||
dataStore datastore.DataStore
|
||||
listener net.Listener
|
||||
shutdown bool
|
||||
globalShutdown chan bool
|
||||
waitgroup *sync.WaitGroup
|
||||
}
|
||||
|
||||
// Init a new Server object
|
||||
func New() *Server {
|
||||
// TODO is two filestores better/worse than sharing w/ smtpd?
|
||||
ds := smtpd.DefaultFileDataStore()
|
||||
cfg := config.GetPop3Config()
|
||||
return &Server{domain: cfg.Domain, dataStore: ds, maxIdleSeconds: cfg.MaxIdleSeconds,
|
||||
waitgroup: new(sync.WaitGroup)}
|
||||
// New creates a new Server struct
|
||||
func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *Server {
|
||||
return &Server{
|
||||
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
|
||||
domain: cfg.Domain,
|
||||
dataStore: ds,
|
||||
maxIdleSeconds: cfg.MaxIdleSeconds,
|
||||
globalShutdown: shutdownChan,
|
||||
waitgroup: new(sync.WaitGroup),
|
||||
}
|
||||
}
|
||||
|
||||
// Main listener loop
|
||||
func (s *Server) Start() {
|
||||
cfg := config.GetPop3Config()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
|
||||
cfg.Ip4address, cfg.Ip4port))
|
||||
// Start the server and listen for connections
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
addr, err := net.ResolveTCPAddr("tcp4", s.host)
|
||||
if err != nil {
|
||||
log.LogError("POP3 Failed to build tcp4 address: %v", err)
|
||||
// TODO More graceful early-shutdown procedure
|
||||
panic(err)
|
||||
log.Errorf("POP3 Failed to build tcp4 address: %v", err)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
log.LogInfo("POP3 listening on TCP4 %v", addr)
|
||||
log.Infof("POP3 listening on TCP4 %v", addr)
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
log.LogError("POP3 failed to start tcp4 listener: %v", err)
|
||||
// TODO More graceful early-shutdown procedure
|
||||
panic(err)
|
||||
log.Errorf("POP3 failed to start tcp4 listener: %v", err)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go s.serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-ctx.Done():
|
||||
}
|
||||
|
||||
log.Tracef("POP3 shutdown requested, connections will be drained")
|
||||
// Closing the listener will cause the serve() go routine to exit
|
||||
if err := s.listener.Close(); err != nil {
|
||||
log.Errorf("Error closing POP3 listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serve is the listen/accept loop
|
||||
func (s *Server) serve(ctx context.Context) {
|
||||
// Handle incoming connections
|
||||
var tempDelay time.Duration
|
||||
for sid := 1; ; sid++ {
|
||||
@@ -62,17 +83,20 @@ func (s *Server) Start() {
|
||||
if max := 1 * time.Second; tempDelay > max {
|
||||
tempDelay = max
|
||||
}
|
||||
log.LogError("POP3 accept error: %v; retrying in %v", err, tempDelay)
|
||||
log.Errorf("POP3 accept error: %v; retrying in %v", err, tempDelay)
|
||||
time.Sleep(tempDelay)
|
||||
continue
|
||||
} else {
|
||||
if s.shutdown {
|
||||
log.LogTrace("POP3 listener shutting down on request")
|
||||
// Permanent error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// POP3 is shutting down
|
||||
return
|
||||
default:
|
||||
// Something went wrong
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
// TODO Implement a max error counter before shutdown?
|
||||
// or maybe attempt to restart POP3
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
tempDelay = 0
|
||||
@@ -82,15 +106,18 @@ func (s *Server) Start() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop requests the POP3 server closes it's listener
|
||||
func (s *Server) Stop() {
|
||||
log.LogTrace("POP3 shutdown requested, connections will be drained")
|
||||
s.shutdown = true
|
||||
s.listener.Close()
|
||||
func (s *Server) emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case _ = <-s.globalShutdown:
|
||||
default:
|
||||
close(s.globalShutdown)
|
||||
}
|
||||
}
|
||||
|
||||
// Drain causes the caller to block until all active POP3 sessions have finished
|
||||
func (s *Server) Drain() {
|
||||
// Wait for sessions to close
|
||||
s.waitgroup.Wait()
|
||||
log.LogTrace("POP3 connections drained")
|
||||
log.Tracef("POP3 connections have drained")
|
||||
}
|
||||
|
||||
201
rest/apiv1_controller.go
Normal file
201
rest/apiv1_controller.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/rest/model"
|
||||
"github.com/jhillyerd/inbucket/stringutil"
|
||||
)
|
||||
|
||||
// MailboxListV1 renders a list of messages in a mailbox
|
||||
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mb, err := ctx.DataStore.MailboxFor(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate not found, likely an IO error
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
messages, err := mb.GetMessages()
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
||||
}
|
||||
log.Tracef("Got %v messsages", len(messages))
|
||||
|
||||
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||
for i, msg := range messages {
|
||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID(),
|
||||
From: msg.From(),
|
||||
To: msg.To(),
|
||||
Subject: msg.Subject(),
|
||||
Date: msg.Date(),
|
||||
Size: msg.Size(),
|
||||
}
|
||||
}
|
||||
return httpd.RenderJSON(w, jmessages)
|
||||
}
|
||||
|
||||
// MailboxShowV1 renders a particular message from a mailbox
|
||||
func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mb, err := ctx.DataStore.MailboxFor(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate not found, likely an IO error
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
msg, err := mb.GetMessage(id)
|
||||
if err == datastore.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate empty, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
header, err := msg.ReadHeader()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReadHeader(%q) failed: %v", id, err)
|
||||
}
|
||||
mime, err := msg.ReadBody()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
|
||||
}
|
||||
|
||||
attachments := make([]*model.JSONMessageAttachmentV1, len(mime.Attachments))
|
||||
for i, att := range mime.Attachments {
|
||||
var content []byte
|
||||
content, err = ioutil.ReadAll(att)
|
||||
var checksum = md5.Sum(content)
|
||||
attachments[i] = &model.JSONMessageAttachmentV1{
|
||||
ContentType: att.ContentType,
|
||||
FileName: att.FileName,
|
||||
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
|
||||
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
|
||||
MD5: hex.EncodeToString(checksum[:]),
|
||||
}
|
||||
}
|
||||
|
||||
return httpd.RenderJSON(w,
|
||||
&model.JSONMessageV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID(),
|
||||
From: msg.From(),
|
||||
To: msg.To(),
|
||||
Subject: msg.Subject(),
|
||||
Date: msg.Date(),
|
||||
Size: msg.Size(),
|
||||
Header: header.Header,
|
||||
Body: &model.JSONMessageBodyV1{
|
||||
Text: mime.Text,
|
||||
HTML: mime.HTML,
|
||||
},
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// MailboxPurgeV1 deletes all messages from a mailbox
|
||||
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mb, err := ctx.DataStore.MailboxFor(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate not found, likely an IO error
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
// Delete all messages
|
||||
err = mb.Purge()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err)
|
||||
}
|
||||
log.Tracef("HTTP purged mailbox for %q", name)
|
||||
|
||||
return httpd.RenderJSON(w, "OK")
|
||||
}
|
||||
|
||||
// MailboxSourceV1 displays the raw source of a message, including headers. Renders text/plain
|
||||
func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mb, err := ctx.DataStore.MailboxFor(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate not found, likely an IO error
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
message, err := mb.GetMessage(id)
|
||||
if err == datastore.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate missing, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
raw, err := message.ReadRaw()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ReadRaw(%q) failed: %v", id, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := io.WriteString(w, *raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxDeleteV1 removes a particular message from a mailbox
|
||||
func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||
id := ctx.Vars["id"]
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mb, err := ctx.DataStore.MailboxFor(name)
|
||||
if err != nil {
|
||||
// This doesn't indicate not found, likely an IO error
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
message, err := mb.GetMessage(id)
|
||||
if err == datastore.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
// This doesn't indicate missing, likely an IO error
|
||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||
}
|
||||
err = message.Delete()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Delete(%q) failed: %v", id, err)
|
||||
}
|
||||
|
||||
return httpd.RenderJSON(w, "OK")
|
||||
}
|
||||
266
rest/apiv1_controller_test.go
Normal file
266
rest/apiv1_controller_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://localhost/api/v1"
|
||||
|
||||
// JSON map keys
|
||||
mailboxKey = "mailbox"
|
||||
idKey = "id"
|
||||
fromKey = "from"
|
||||
toKey = "to"
|
||||
subjectKey = "subject"
|
||||
dateKey = "date"
|
||||
sizeKey = "size"
|
||||
headerKey = "header"
|
||||
bodyKey = "body"
|
||||
textKey = "text"
|
||||
htmlKey = "html"
|
||||
)
|
||||
|
||||
func TestRestMailboxList(t *testing.T) {
|
||||
// Setup
|
||||
ds := &datastore.MockDataStore{}
|
||||
logbuf := setupWebServer(ds)
|
||||
|
||||
// Test invalid mailbox name
|
||||
w, err := testRestGet(baseURL + "/mailbox/foo@bar")
|
||||
expectCode := 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Test empty mailbox
|
||||
emptybox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "empty").Return(emptybox, nil)
|
||||
emptybox.On("GetMessages").Return([]datastore.Message{}, nil)
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Test MailboxFor error
|
||||
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
|
||||
w, err = testRestGet(baseURL + "/mailbox/error")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
|
||||
// Test MailboxFor error
|
||||
error2box := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "error2").Return(error2box, nil)
|
||||
error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2"))
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/error2")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Test JSON message headers
|
||||
data1 := &InputMessageData{
|
||||
Mailbox: "good",
|
||||
ID: "0001",
|
||||
From: "from1",
|
||||
To: []string{"to1"},
|
||||
Subject: "subject 1",
|
||||
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
|
||||
}
|
||||
data2 := &InputMessageData{
|
||||
Mailbox: "good",
|
||||
ID: "0002",
|
||||
From: "from2",
|
||||
To: []string{"to1"},
|
||||
Subject: "subject 2",
|
||||
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)),
|
||||
}
|
||||
goodbox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "good").Return(goodbox, nil)
|
||||
msg1 := data1.MockMessage()
|
||||
msg2 := data2.MockMessage()
|
||||
goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil)
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Check JSON
|
||||
dec := json.NewDecoder(w.Body)
|
||||
var result []interface{}
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
t.Errorf("Failed to decode JSON: %v", err)
|
||||
}
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 results, got %v", len(result))
|
||||
}
|
||||
if errors := data1.CompareToJSONHeaderMap(result[0]); len(errors) > 0 {
|
||||
t.Logf("%v", result[0])
|
||||
for _, e := range errors {
|
||||
t.Error(e)
|
||||
}
|
||||
}
|
||||
if errors := data2.CompareToJSONHeaderMap(result[1]); len(errors) > 0 {
|
||||
t.Logf("%v", result[1])
|
||||
for _, e := range errors {
|
||||
t.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestMessage(t *testing.T) {
|
||||
// Setup
|
||||
ds := &datastore.MockDataStore{}
|
||||
logbuf := setupWebServer(ds)
|
||||
|
||||
// Test invalid mailbox name
|
||||
w, err := testRestGet(baseURL + "/mailbox/foo@bar/0001")
|
||||
expectCode := 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Test requesting a message that does not exist
|
||||
emptybox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "empty").Return(emptybox, nil)
|
||||
emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist)
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
|
||||
expectCode = 404
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Test MailboxFor error
|
||||
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
|
||||
w, err = testRestGet(baseURL + "/mailbox/error/0001")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
|
||||
// Test GetMessage error
|
||||
error2box := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "error2").Return(error2box, nil)
|
||||
error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2"))
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/error2/0001")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Test JSON message headers
|
||||
data1 := &InputMessageData{
|
||||
Mailbox: "good",
|
||||
ID: "0001",
|
||||
From: "from1",
|
||||
Subject: "subject 1",
|
||||
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
|
||||
Header: mail.Header{
|
||||
"To": []string{"fred@fish.com", "keyword@nsa.gov"},
|
||||
"From": []string{"noreply@inbucket.org"},
|
||||
},
|
||||
Text: "This is some text",
|
||||
HTML: "This is some HTML",
|
||||
}
|
||||
goodbox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "good").Return(goodbox, nil)
|
||||
msg1 := data1.MockMessage()
|
||||
goodbox.On("GetMessage", "0001").Return(msg1, nil)
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good/0001")
|
||||
expectCode = 200
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != expectCode {
|
||||
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
|
||||
}
|
||||
|
||||
// Check JSON
|
||||
dec := json.NewDecoder(w.Body)
|
||||
var result map[string]interface{}
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
t.Errorf("Failed to decode JSON: %v", err)
|
||||
}
|
||||
|
||||
if errors := data1.CompareToJSONMessageMap(result); len(errors) > 0 {
|
||||
t.Logf("%v", result)
|
||||
for _, e := range errors {
|
||||
t.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
144
rest/client/apiv1_client.go
Normal file
144
rest/client/apiv1_client.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Package client provides a basic REST client for Inbucket
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/rest/model"
|
||||
)
|
||||
|
||||
// Client accesses the Inbucket REST API v1
|
||||
type Client struct {
|
||||
restClient
|
||||
}
|
||||
|
||||
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
|
||||
// "http://localhost:9000"
|
||||
func New(baseURL string) (*Client, error) {
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{
|
||||
restClient{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: parsedURL,
|
||||
},
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListMailbox returns a list of messages for the requested mailbox
|
||||
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||
err = c.doJSON("GET", uri, &headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, h := range headers {
|
||||
h.client = c
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetMessage returns the message details given a mailbox name and message ID.
|
||||
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||
err = c.doJSON("GET", uri, &message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message.client = c
|
||||
return
|
||||
}
|
||||
|
||||
// GetMessageSource returns the message source given a mailbox name and message ID.
|
||||
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
|
||||
resp, err := c.do("GET", uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil,
|
||||
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.ReadFrom(resp.Body)
|
||||
return buf, err
|
||||
}
|
||||
|
||||
// DeleteMessage deletes a single message given the mailbox name and message ID.
|
||||
func (c *Client) DeleteMessage(name, id string) error {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
|
||||
resp, err := c.do("DELETE", uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeMailbox deletes all messages in the given mailbox
|
||||
func (c *Client) PurgeMailbox(name string) error {
|
||||
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
|
||||
resp, err := c.do("DELETE", uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageHeader represents an Inbucket message sans content
|
||||
type MessageHeader struct {
|
||||
*model.JSONMessageHeaderV1
|
||||
client *Client
|
||||
}
|
||||
|
||||
// GetMessage returns this message with content
|
||||
func (h *MessageHeader) GetMessage() (message *Message, err error) {
|
||||
return h.client.GetMessage(h.Mailbox, h.ID)
|
||||
}
|
||||
|
||||
// GetSource returns the source for this message
|
||||
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
|
||||
return h.client.GetMessageSource(h.Mailbox, h.ID)
|
||||
}
|
||||
|
||||
// Delete deletes this message from the mailbox
|
||||
func (h *MessageHeader) Delete() error {
|
||||
return h.client.DeleteMessage(h.Mailbox, h.ID)
|
||||
}
|
||||
|
||||
// Message represents an Inbucket message including content
|
||||
type Message struct {
|
||||
*model.JSONMessageV1
|
||||
client *Client
|
||||
}
|
||||
|
||||
// GetSource returns the source for this message
|
||||
func (m *Message) GetSource() (*bytes.Buffer, error) {
|
||||
return m.client.GetMessageSource(m.Mailbox, m.ID)
|
||||
}
|
||||
|
||||
// Delete deletes this message from the mailbox
|
||||
func (m *Message) Delete() error {
|
||||
return m.client.DeleteMessage(m.Mailbox, m.ID)
|
||||
}
|
||||
323
rest/client/apiv1_client_test.go
Normal file
323
rest/client/apiv1_client_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClientV1ListMailbox(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
_, _ = c.ListMailbox("testbox")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1GetMessage(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
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)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1GetMessageSource(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{
|
||||
body: "message source",
|
||||
}
|
||||
c.client = mth
|
||||
|
||||
// Method under test
|
||||
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
|
||||
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/20170107T224128-0000/source"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "message source"
|
||||
got = source.String()
|
||||
if got != want {
|
||||
t.Errorf("Source == %q, want: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1DeleteMessage(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1PurgeMailbox(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{}
|
||||
c.client = mth
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientV1MessageHeader(t *testing.T) {
|
||||
var want, got string
|
||||
response := `[
|
||||
{
|
||||
"mailbox":"mailbox1",
|
||||
"id":"id1",
|
||||
"from":"from1",
|
||||
"subject":"subject1",
|
||||
"date":"2017-01-01T00:00:00.000-07:00",
|
||||
"size":100
|
||||
}
|
||||
]`
|
||||
|
||||
c, err := New(baseURLStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mth := &mockHTTPClient{body: response}
|
||||
c.client = mth
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Test MessageHeader.Delete()
|
||||
mth.body = ""
|
||||
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()
|
||||
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/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 = `{
|
||||
"mailbox":"mailbox1",
|
||||
"id":"id1",
|
||||
"from":"from1",
|
||||
"subject":"subject1",
|
||||
"date":"2017-01-01T00:00:00.000-07:00",
|
||||
"size":100
|
||||
}`
|
||||
message, err := header.GetMessage()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message == nil {
|
||||
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 = ""
|
||||
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()
|
||||
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/mailbox1/id1/source"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
59
rest/client/rest.go
Normal file
59
rest/client/rest.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// httpClient allows http.Client to be mocked for tests
|
||||
type httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Generic REST restClient
|
||||
type restClient struct {
|
||||
client httpClient
|
||||
baseURL *url.URL
|
||||
}
|
||||
|
||||
// do performs an HTTP request with this client and returns the response
|
||||
func (c *restClient) do(method, uri string) (*http.Response, error) {
|
||||
rel, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := c.baseURL.ResolveReference(rel)
|
||||
|
||||
// Build the request
|
||||
req, err := http.NewRequest(method, url.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send the request
|
||||
return c.client.Do(req)
|
||||
}
|
||||
|
||||
// doGet performs an HTTP request with this client and marshalls the JSON response into v
|
||||
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
|
||||
resp, err := c.do(method, uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
// Decode response body
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
125
rest/client/rest_test.go
Normal file
125
rest/client/rest_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const baseURLStr = "http://test.local:8080"
|
||||
|
||||
var baseURL *url.URL
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
baseURL, err = url.Parse(baseURLStr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type mockHTTPClient struct {
|
||||
req *http.Request
|
||||
statusCode int
|
||||
body string
|
||||
}
|
||||
|
||||
func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) {
|
||||
m.req = req
|
||||
if m.statusCode == 0 {
|
||||
m.statusCode = 200
|
||||
}
|
||||
resp = &http.Response{
|
||||
StatusCode: m.statusCode,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestDo(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
mth := &mockHTTPClient{}
|
||||
c := &restClient{mth, baseURL}
|
||||
|
||||
_, err := c.do("POST", "/dopost")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want = "POST"
|
||||
got = mth.req.Method
|
||||
if got != want {
|
||||
t.Errorf("req.Method == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = baseURLStr + "/dopost"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSON(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
mth := &mockHTTPClient{
|
||||
body: `{"foo": "bar"}`,
|
||||
}
|
||||
c := &restClient{mth, baseURL}
|
||||
|
||||
var v map[string]interface{}
|
||||
err := c.doJSON("GET", "/doget", &v)
|
||||
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 + "/doget"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "bar"
|
||||
if val, ok := v["foo"]; ok {
|
||||
got = val.(string)
|
||||
if got != want {
|
||||
t.Errorf("map[foo] == %q, want: %q", got, want)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Map did not contain key foo, want: %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONNilV(t *testing.T) {
|
||||
var want, got string
|
||||
|
||||
mth := &mockHTTPClient{}
|
||||
c := &restClient{mth, baseURL}
|
||||
|
||||
err := c.doJSON("GET", "/doget", nil)
|
||||
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 + "/doget"
|
||||
got = mth.req.URL.String()
|
||||
if got != want {
|
||||
t.Errorf("req.URL == %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
45
rest/model/apiv1_model.go
Normal file
45
rest/model/apiv1_model.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||
type JSONMessageHeaderV1 struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
||||
type JSONMessageV1 struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
Body *JSONMessageBodyV1 `json:"body"`
|
||||
Header mail.Header `json:"header"`
|
||||
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
||||
}
|
||||
|
||||
type JSONMessageAttachmentV1 struct {
|
||||
FileName string `json:"filename"`
|
||||
ContentType string `json:"content-type"`
|
||||
DownloadLink string `json:"download-link"`
|
||||
ViewLink string `json:"view-link"`
|
||||
MD5 string `json:"md5"`
|
||||
}
|
||||
|
||||
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
|
||||
type JSONMessageBodyV1 struct {
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
23
rest/routes.go
Normal file
23
rest/routes.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package rest
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
import "github.com/jhillyerd/inbucket/httpd"
|
||||
|
||||
// SetupRoutes populates the routes for the REST interface
|
||||
func SetupRoutes(r *mux.Router) {
|
||||
// API v1
|
||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||
httpd.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
|
||||
r.Path("/api/v1/mailbox/{name}").Handler(
|
||||
httpd.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
httpd.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
|
||||
httpd.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
|
||||
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
|
||||
httpd.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
|
||||
r.Path("/api/v1/monitor/messages").Handler(
|
||||
httpd.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
|
||||
r.Path("/api/v1/monitor/messages/{name}").Handler(
|
||||
httpd.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
|
||||
}
|
||||
195
rest/socketv1_controller.go
Normal file
195
rest/socketv1_controller.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/rest/model"
|
||||
"github.com/jhillyerd/inbucket/stringutil"
|
||||
)
|
||||
|
||||
const (
|
||||
// Time allowed to write a message to the peer.
|
||||
writeWait = 10 * time.Second
|
||||
|
||||
// Send pings to peer with this period. Must be less than pongWait.
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
|
||||
// Time allowed to read the next pong message from the peer.
|
||||
pongWait = 60 * time.Second
|
||||
|
||||
// Maximum message size allowed from peer.
|
||||
maxMessageSize = 512
|
||||
)
|
||||
|
||||
// options for gorilla connection upgrader
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
}
|
||||
|
||||
// msgListener handles messages from the msghub
|
||||
type msgListener struct {
|
||||
hub *msghub.Hub // Global message hub
|
||||
c chan msghub.Message // Queue of messages from Receive()
|
||||
mailbox string // Name of mailbox to monitor, "" == all mailboxes
|
||||
}
|
||||
|
||||
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
|
||||
// messages sent to WebSocket to that mailbox only.
|
||||
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
|
||||
ml := &msgListener{
|
||||
hub: hub,
|
||||
c: make(chan msghub.Message, 100),
|
||||
mailbox: mailbox,
|
||||
}
|
||||
hub.AddListener(ml)
|
||||
return ml
|
||||
}
|
||||
|
||||
// Receive handles an incoming message
|
||||
func (ml *msgListener) Receive(msg msghub.Message) error {
|
||||
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
||||
// Did not match mailbox name
|
||||
return nil
|
||||
}
|
||||
ml.c <- msg
|
||||
return nil
|
||||
}
|
||||
|
||||
// WSReader makes sure the websocket client is still connected, discards any messages from client
|
||||
func (ml *msgListener) WSReader(conn *websocket.Conn) {
|
||||
defer ml.Close()
|
||||
conn.SetReadLimit(maxMessageSize)
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
log.Tracef("HTTP[%v] Got WebSocket pong", conn.RemoteAddr())
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(
|
||||
err,
|
||||
websocket.CloseNormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived,
|
||||
) {
|
||||
// Unexpected close code
|
||||
log.Warnf("HTTP[%v] WebSocket error: %v", conn.RemoteAddr(), err)
|
||||
} else {
|
||||
log.Tracef("HTTP[%v] Closing WebSocket", conn.RemoteAddr())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WSWriter makes sure the websocket client is still connected
|
||||
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
ml.Close()
|
||||
}()
|
||||
|
||||
// Handle messages from hub until msgListener is closed
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-ml.c:
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
// msgListener closed, exit
|
||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
header := &model.JSONMessageHeaderV1{
|
||||
Mailbox: msg.Mailbox,
|
||||
ID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
Subject: msg.Subject,
|
||||
Date: msg.Date,
|
||||
Size: msg.Size,
|
||||
}
|
||||
if conn.WriteJSON(header) != nil {
|
||||
// Write failed
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Send ping
|
||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
|
||||
// Write error
|
||||
return
|
||||
}
|
||||
log.Tracef("HTTP[%v] Sent WebSocket ping", conn.RemoteAddr())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close removes the listener registration
|
||||
func (ml *msgListener) Close() {
|
||||
select {
|
||||
case <-ml.c:
|
||||
// Already closed
|
||||
default:
|
||||
ml.hub.RemoveListener(ml)
|
||||
close(ml.c)
|
||||
}
|
||||
}
|
||||
|
||||
func MonitorAllMessagesV1(
|
||||
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
// Upgrade to Websocket
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpd.ExpWebSocketConnectsCurrent.Add(1)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
httpd.ExpWebSocketConnectsCurrent.Add(-1)
|
||||
}()
|
||||
|
||||
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
|
||||
|
||||
// Create, register listener; then interact with conn
|
||||
ml := newMsgListener(ctx.MsgHub, "")
|
||||
go ml.WSWriter(conn)
|
||||
ml.WSReader(conn)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MonitorMailboxMessagesV1(
|
||||
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Upgrade to Websocket
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpd.ExpWebSocketConnectsCurrent.Add(1)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
httpd.ExpWebSocketConnectsCurrent.Add(-1)
|
||||
}()
|
||||
|
||||
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
|
||||
|
||||
// Create, register listener; then interact with conn
|
||||
ml := newMsgListener(ctx.MsgHub, name)
|
||||
go ml.WSWriter(conn)
|
||||
ml.WSReader(conn)
|
||||
|
||||
return nil
|
||||
}
|
||||
207
rest/testutils_test.go
Normal file
207
rest/testutils_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
type InputMessageData struct {
|
||||
Mailbox, ID, From, Subject string
|
||||
To []string
|
||||
Date time.Time
|
||||
Size int
|
||||
Header mail.Header
|
||||
HTML, Text string
|
||||
}
|
||||
|
||||
func (d *InputMessageData) MockMessage() *datastore.MockMessage {
|
||||
msg := &datastore.MockMessage{}
|
||||
msg.On("ID").Return(d.ID)
|
||||
msg.On("From").Return(d.From)
|
||||
msg.On("To").Return(d.To)
|
||||
msg.On("Subject").Return(d.Subject)
|
||||
msg.On("Date").Return(d.Date)
|
||||
msg.On("Size").Return(d.Size)
|
||||
gomsg := &mail.Message{
|
||||
Header: d.Header,
|
||||
}
|
||||
msg.On("ReadHeader").Return(gomsg, nil)
|
||||
body := &enmime.Envelope{
|
||||
Text: d.Text,
|
||||
HTML: d.HTML,
|
||||
}
|
||||
msg.On("ReadBody").Return(body, nil)
|
||||
return msg
|
||||
}
|
||||
|
||||
// isJSONStringEqual is a utility function to return a nicely formatted message when
|
||||
// comparing a string to a value received from a JSON map.
|
||||
func isJSONStringEqual(key, expected string, received interface{}) (message string, ok bool) {
|
||||
if value, ok := received.(string); ok {
|
||||
if expected == value {
|
||||
return "", true
|
||||
}
|
||||
return fmt.Sprintf("Expected value of key %v to be %q, got %q", key, expected, value), false
|
||||
}
|
||||
return fmt.Sprintf("Expected value of key %v to be a string, got %T", key, received), false
|
||||
}
|
||||
|
||||
// isJSONNumberEqual is a utility function to return a nicely formatted message when
|
||||
// comparing an float64 to a value received from a JSON map.
|
||||
func isJSONNumberEqual(key string, expected float64, received interface{}) (message string, ok bool) {
|
||||
if value, ok := received.(float64); ok {
|
||||
if expected == value {
|
||||
return "", true
|
||||
}
|
||||
return fmt.Sprintf("Expected %v to be %v, got %v", key, expected, value), false
|
||||
}
|
||||
return fmt.Sprintf("Expected %v to be a string, got %T", key, received), false
|
||||
}
|
||||
|
||||
// CompareToJSONHeaderMap compares InputMessageData to a header map decoded from JSON,
|
||||
// returning a list of things that did not match.
|
||||
func (d *InputMessageData) CompareToJSONHeaderMap(json interface{}) (errors []string) {
|
||||
if m, ok := json.(map[string]interface{}); ok {
|
||||
if msg, ok := isJSONStringEqual(mailboxKey, d.Mailbox, m[mailboxKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
if msg, ok := isJSONStringEqual(idKey, d.ID, m[idKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
if msg, ok := isJSONStringEqual(fromKey, d.From, m[fromKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
for i, inputTo := range d.To {
|
||||
if msg, ok := isJSONStringEqual(toKey, inputTo, m[toKey].([]interface{})[i]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
}
|
||||
if msg, ok := isJSONStringEqual(subjectKey, d.Subject, m[subjectKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00")
|
||||
if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json))
|
||||
}
|
||||
|
||||
// CompareToJSONMessageMap compares InputMessageData to a message map decoded from JSON,
|
||||
// returning a list of things that did not match.
|
||||
func (d *InputMessageData) CompareToJSONMessageMap(json interface{}) (errors []string) {
|
||||
// We need to check the same values as header first
|
||||
errors = d.CompareToJSONHeaderMap(json)
|
||||
|
||||
if m, ok := json.(map[string]interface{}); ok {
|
||||
// Get nested body map
|
||||
if m[bodyKey] != nil {
|
||||
if body, ok := m[bodyKey].(map[string]interface{}); ok {
|
||||
if msg, ok := isJSONStringEqual(textKey, d.Text, body[textKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
if msg, ok := isJSONStringEqual(htmlKey, d.HTML, body[htmlKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
} else {
|
||||
panic(fmt.Sprintf("Expected map[string]interface{} in json key %q, got %T",
|
||||
bodyKey, m[bodyKey]))
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, fmt.Sprintf("Expected body in JSON %q but it was nil", bodyKey))
|
||||
}
|
||||
exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00")
|
||||
if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
|
||||
// Get nested header map
|
||||
if m[headerKey] != nil {
|
||||
if header, ok := m[headerKey].(map[string]interface{}); ok {
|
||||
// Loop over input (expected) header names
|
||||
for name, keyInputHeaders := range d.Header {
|
||||
// Make sure expected header name exists in received JSON
|
||||
if keyOutputVals, ok := header[name]; ok {
|
||||
if keyOutputHeaders, ok := keyOutputVals.([]interface{}); ok {
|
||||
// Loop over input (expected) header values
|
||||
for _, inputHeader := range keyInputHeaders {
|
||||
hasValue := false
|
||||
// Look for expected value in received headers
|
||||
for _, outputHeader := range keyOutputHeaders {
|
||||
if inputHeader == outputHeader {
|
||||
hasValue = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasValue {
|
||||
errors = append(errors, fmt.Sprintf(
|
||||
"JSON %v[%q] missing value %q", headerKey, name, inputHeader))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// keyOutputValues was not a slice of interface{}
|
||||
panic(fmt.Sprintf("Expected []interface{} in %v[%q], got %T", headerKey,
|
||||
name, keyOutputVals))
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, fmt.Sprintf("JSON %v missing key %q", headerKey, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, fmt.Sprintf("Expected header in JSON %q but it was nil", headerKey))
|
||||
}
|
||||
} else {
|
||||
panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json))
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req.Header.Add("Accept", "application/json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
httpd.Router.ServeHTTP(w, req)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func setupWebServer(ds datastore.DataStore) *bytes.Buffer {
|
||||
// Capture log output
|
||||
buf := new(bytes.Buffer)
|
||||
log.SetOutput(buf)
|
||||
|
||||
// Have to reset default mux to prevent duplicate routes
|
||||
http.DefaultServeMux = http.NewServeMux()
|
||||
cfg := config.WebConfig{
|
||||
TemplateDir: "../themes/bootstrap/templates",
|
||||
PublicDir: "../themes/bootstrap/public",
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
httpd.Initialize(cfg, shutdownChan, ds, &msghub.Hub{})
|
||||
SetupRoutes(httpd.Router)
|
||||
|
||||
return buf
|
||||
}
|
||||
110
sanitize/css.go
Normal file
110
sanitize/css.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package sanitize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/css/scanner"
|
||||
)
|
||||
|
||||
// propertyRule may someday allow control of what values are valid for a particular property.
|
||||
type propertyRule struct{}
|
||||
|
||||
var allowedProperties = map[string]propertyRule{
|
||||
"align": {},
|
||||
"background-color": {},
|
||||
"border": {},
|
||||
"border-bottom": {},
|
||||
"border-left": {},
|
||||
"border-radius": {},
|
||||
"border-right": {},
|
||||
"border-top": {},
|
||||
"box-sizing": {},
|
||||
"clear": {},
|
||||
"color": {},
|
||||
"content": {},
|
||||
"display": {},
|
||||
"font-family": {},
|
||||
"font-size": {},
|
||||
"font-weight": {},
|
||||
"height": {},
|
||||
"line-height": {},
|
||||
"margin": {},
|
||||
"margin-bottom": {},
|
||||
"margin-left": {},
|
||||
"margin-right": {},
|
||||
"margin-top": {},
|
||||
"max-height": {},
|
||||
"max-width": {},
|
||||
"overflow": {},
|
||||
"padding": {},
|
||||
"padding-bottom": {},
|
||||
"padding-left": {},
|
||||
"padding-right": {},
|
||||
"padding-top": {},
|
||||
"table-layout": {},
|
||||
"text-align": {},
|
||||
"text-decoration": {},
|
||||
"text-shadow": {},
|
||||
"vertical-align": {},
|
||||
"width": {},
|
||||
"word-break": {},
|
||||
}
|
||||
|
||||
// Handler Token, return next state.
|
||||
type stateHandler func(b *bytes.Buffer, t *scanner.Token) stateHandler
|
||||
|
||||
func sanitizeStyle(input string) string {
|
||||
b := &bytes.Buffer{}
|
||||
scan := scanner.New(input)
|
||||
state := stateStart
|
||||
for {
|
||||
t := scan.Next()
|
||||
if t.Type == scanner.TokenEOF {
|
||||
return b.String()
|
||||
}
|
||||
if t.Type == scanner.TokenError {
|
||||
return ""
|
||||
}
|
||||
state = state(b, t)
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stateStart(b *bytes.Buffer, t *scanner.Token) stateHandler {
|
||||
switch t.Type {
|
||||
case scanner.TokenIdent:
|
||||
_, ok := allowedProperties[strings.ToLower(t.Value)]
|
||||
if !ok {
|
||||
return stateEat
|
||||
}
|
||||
b.WriteString(t.Value)
|
||||
return stateValid
|
||||
case scanner.TokenS:
|
||||
return stateStart
|
||||
}
|
||||
// Unexpected type.
|
||||
b.WriteString("/*" + t.Type.String() + "*/")
|
||||
return stateEat
|
||||
}
|
||||
|
||||
func stateEat(b *bytes.Buffer, t *scanner.Token) stateHandler {
|
||||
if t.Type == scanner.TokenChar && t.Value == ";" {
|
||||
// Done eating.
|
||||
return stateStart
|
||||
}
|
||||
// Throw away this token.
|
||||
return stateEat
|
||||
}
|
||||
|
||||
func stateValid(b *bytes.Buffer, t *scanner.Token) stateHandler {
|
||||
state := stateValid
|
||||
if t.Type == scanner.TokenChar && t.Value == ";" {
|
||||
// End of property.
|
||||
state = stateStart
|
||||
}
|
||||
b.WriteString(t.Value)
|
||||
return state
|
||||
}
|
||||
34
sanitize/css_test.go
Normal file
34
sanitize/css_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package sanitize
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeStyle(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{
|
||||
"color: red;",
|
||||
"color: red;",
|
||||
},
|
||||
{
|
||||
"background-color: black; color: white",
|
||||
"background-color: black;color: white",
|
||||
},
|
||||
{
|
||||
"background-color: black; invalid: true; color: white",
|
||||
"background-color: black;color: white",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := sanitizeStyle(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("got: %q, want: %q, input: %q", got, tc.want, tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
88
sanitize/html.go
Normal file
88
sanitize/html.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package sanitize
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
cssSafe = regexp.MustCompile(".*")
|
||||
policy = bluemonday.UGCPolicy().
|
||||
AllowElements("center").
|
||||
AllowAttrs("style").Matching(cssSafe).Globally()
|
||||
)
|
||||
|
||||
func HTML(html string) (output string, err error) {
|
||||
output, err = sanitizeStyleTags(html)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output = policy.Sanitize(output)
|
||||
return
|
||||
}
|
||||
|
||||
func sanitizeStyleTags(input string) (string, error) {
|
||||
r := strings.NewReader(input)
|
||||
b := &bytes.Buffer{}
|
||||
if err := styleTagFilter(b, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func styleTagFilter(w io.Writer, r io.Reader) error {
|
||||
bw := bufio.NewWriter(w)
|
||||
b := make([]byte, 256)
|
||||
z := html.NewTokenizer(r)
|
||||
for {
|
||||
b = b[:0]
|
||||
tt := z.Next()
|
||||
switch tt {
|
||||
case html.ErrorToken:
|
||||
err := z.Err()
|
||||
if err == io.EOF {
|
||||
return bw.Flush()
|
||||
}
|
||||
return err
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
name, hasAttr := z.TagName()
|
||||
if !hasAttr {
|
||||
bw.Write(z.Raw())
|
||||
continue
|
||||
}
|
||||
b = append(b, '<')
|
||||
b = append(b, name...)
|
||||
for {
|
||||
key, val, more := z.TagAttr()
|
||||
strval := string(val)
|
||||
style := false
|
||||
if strings.ToLower(string(key)) == "style" {
|
||||
style = true
|
||||
strval = sanitizeStyle(strval)
|
||||
}
|
||||
if !style || strval != "" {
|
||||
b = append(b, ' ')
|
||||
b = append(b, key...)
|
||||
b = append(b, '=', '"')
|
||||
b = append(b, []byte(html.EscapeString(strval))...)
|
||||
b = append(b, '"')
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
if tt == html.SelfClosingTagToken {
|
||||
b = append(b, '/')
|
||||
}
|
||||
bw.Write(append(b, '>'))
|
||||
default:
|
||||
bw.Write(z.Raw())
|
||||
}
|
||||
}
|
||||
}
|
||||
171
sanitize/html_test.go
Normal file
171
sanitize/html_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package sanitize_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jhillyerd/inbucket/sanitize"
|
||||
)
|
||||
|
||||
// TestHTMLPlainStrings test plain text passthrough
|
||||
func TestHTMLPlainStrings(t *testing.T) {
|
||||
testStrings := []string{
|
||||
"",
|
||||
"plain string",
|
||||
"one < two",
|
||||
}
|
||||
for _, ts := range testStrings {
|
||||
t.Run(ts, func(t *testing.T) {
|
||||
got, err := sanitize.HTML(ts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ts {
|
||||
t.Errorf("Got: %q, want: %q", got, ts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLSimpleFormatting tests basic tags we should allow
|
||||
func TestHTMLSimpleFormatting(t *testing.T) {
|
||||
testStrings := []string{
|
||||
"<p>paragraph</p>",
|
||||
"<b>bold</b>",
|
||||
"<i>italic</b>",
|
||||
"<em>emphasis</em>",
|
||||
"<strong>strong</strong>",
|
||||
"<div><span>text</span></div>",
|
||||
"<center>text</center>",
|
||||
}
|
||||
for _, ts := range testStrings {
|
||||
t.Run(ts, func(t *testing.T) {
|
||||
got, err := sanitize.HTML(ts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != ts {
|
||||
t.Errorf("Got: %q, want: %q", got, ts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLScriptTags tests some strings with JavaScript
|
||||
func TestHTMLScriptTags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{
|
||||
`safe<script>nope</script>`,
|
||||
`safe`,
|
||||
},
|
||||
{
|
||||
`<a onblur="alert(something)" href="http://mysite.com">mysite</a>`,
|
||||
`<a href="http://mysite.com" rel="nofollow">mysite</a>`,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got, err := sanitize.HTML(tc.input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Got: %q, want: %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeStyleTags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
``,
|
||||
``,
|
||||
},
|
||||
{
|
||||
"open",
|
||||
`<div>`,
|
||||
`<div>`,
|
||||
},
|
||||
{
|
||||
"open close",
|
||||
`<div></div>`,
|
||||
`<div></div>`,
|
||||
},
|
||||
{
|
||||
"inner text",
|
||||
`<div>foo bar</div>`,
|
||||
`<div>foo bar</div>`,
|
||||
},
|
||||
{
|
||||
"self close",
|
||||
`<br/>`,
|
||||
`<br/>`,
|
||||
},
|
||||
{
|
||||
"open params",
|
||||
`<div id="me">`,
|
||||
`<div id="me">`,
|
||||
},
|
||||
{
|
||||
"open params squote",
|
||||
`<div id="me" title='best'>`,
|
||||
`<div id="me" title="best">`,
|
||||
},
|
||||
{
|
||||
"open style",
|
||||
`<div id="me" style="color: red;">`,
|
||||
`<div id="me" style="color: red;">`,
|
||||
},
|
||||
{
|
||||
"open style squote",
|
||||
`<div id="me" style='color: red;'>`,
|
||||
`<div id="me" style="color: red;">`,
|
||||
},
|
||||
{
|
||||
"open style mixed case",
|
||||
`<div id="me" StYlE="color: red;">`,
|
||||
`<div id="me" style="color: red;">`,
|
||||
},
|
||||
{
|
||||
"closed style",
|
||||
`<br style="border: 1px solid red;"/>`,
|
||||
`<br style="border: 1px solid red;"/>`,
|
||||
},
|
||||
{
|
||||
"mixed case style",
|
||||
`<br StYlE="border: 1px solid red;"/>`,
|
||||
`<br style="border: 1px solid red;"/>`,
|
||||
},
|
||||
{
|
||||
"mixed case invalid style",
|
||||
`<br StYlE="position: fixed;"/>`,
|
||||
`<br/>`,
|
||||
},
|
||||
{
|
||||
"mixed",
|
||||
`<p id='i' title="cla'zz" style="font-size: 25px;"><b>some text</b></p>`,
|
||||
`<p id="i" title="cla'zz" style="font-size: 25px;"><b>some text</b></p>`,
|
||||
},
|
||||
{
|
||||
"invalid styles",
|
||||
`<div id="me" style='position: absolute;'>`,
|
||||
`<div id="me">`,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := sanitize.HTML(tc.input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("input: %s\ngot : %s\nwant: %s", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"io"
|
||||
"net/mail"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DataStore interface {
|
||||
MailboxFor(emailAddress string) (Mailbox, error)
|
||||
AllMailboxes() ([]Mailbox, error)
|
||||
}
|
||||
|
||||
type Mailbox interface {
|
||||
GetMessages() ([]Message, error)
|
||||
GetMessage(id string) (Message, error)
|
||||
Purge() error
|
||||
NewMessage() Message
|
||||
String() string
|
||||
}
|
||||
|
||||
type Message interface {
|
||||
Id() string
|
||||
From() string
|
||||
Date() time.Time
|
||||
Subject() string
|
||||
RawReader() (reader io.ReadCloser, err error)
|
||||
ReadHeader() (msg *mail.Message, err error)
|
||||
ReadBody() (body *enmime.MIMEBody, err error)
|
||||
ReadRaw() (raw *string, err error)
|
||||
Append(data []byte) error
|
||||
Close() error
|
||||
Delete() error
|
||||
String() string
|
||||
Size() int64
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Name of index file in each mailbox
|
||||
const INDEX_FILE = "index.gob"
|
||||
|
||||
// We lock this when reading/writing an index file, this is a bottleneck because
|
||||
// it's a single lock even if we have a million index files
|
||||
var indexLock = new(sync.RWMutex)
|
||||
|
||||
var ErrNotWritable = errors.New("Message not writable")
|
||||
|
||||
// Global because we only want one regardless of the number of DataStore objects
|
||||
var countChannel = make(chan int, 10)
|
||||
|
||||
func init() {
|
||||
// Start generator
|
||||
go countGenerator(countChannel)
|
||||
}
|
||||
|
||||
// Populates the channel with numbers
|
||||
func countGenerator(c chan int) {
|
||||
for i := 0; true; i = (i + 1) % 10000 {
|
||||
c <- i
|
||||
}
|
||||
}
|
||||
|
||||
// A DataStore is the root of the mail storage hiearchy. It provides access to
|
||||
// Mailbox objects
|
||||
type FileDataStore struct {
|
||||
path string
|
||||
mailPath string
|
||||
}
|
||||
|
||||
// NewFileDataStore creates a new DataStore object using the specified path
|
||||
func NewFileDataStore(path string) DataStore {
|
||||
mailPath := filepath.Join(path, "mail")
|
||||
if _, err := os.Stat(mailPath); err != nil {
|
||||
// Mail datastore does not yet exist
|
||||
os.MkdirAll(mailPath, 0770)
|
||||
}
|
||||
return &FileDataStore{path: path, mailPath: mailPath}
|
||||
}
|
||||
|
||||
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
|
||||
// construct it's path.
|
||||
func DefaultFileDataStore() DataStore {
|
||||
path, err := config.Config.String("datastore", "path")
|
||||
if err != nil {
|
||||
log.LogError("Error getting datastore path: %v", err)
|
||||
return nil
|
||||
}
|
||||
if path == "" {
|
||||
log.LogError("No value configured for datastore path")
|
||||
return nil
|
||||
}
|
||||
return NewFileDataStore(path)
|
||||
}
|
||||
|
||||
// Retrieves the Mailbox object for a specified email address, if the mailbox
|
||||
// does not exist, it will attempt to create it.
|
||||
func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) {
|
||||
name, err := ParseMailboxName(emailAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := HashMailboxName(name)
|
||||
s1 := dir[0:3]
|
||||
s2 := dir[0:6]
|
||||
path := filepath.Join(ds.mailPath, s1, s2, dir)
|
||||
indexPath := filepath.Join(path, INDEX_FILE)
|
||||
|
||||
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
|
||||
indexPath: indexPath}, nil
|
||||
}
|
||||
|
||||
// AllMailboxes returns a slice with all Mailboxes
|
||||
func (ds *FileDataStore) AllMailboxes() ([]Mailbox, error) {
|
||||
mailboxes := make([]Mailbox, 0, 100)
|
||||
infos1, err := ioutil.ReadDir(ds.mailPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Loop over level 1 directories
|
||||
for _, inf1 := range infos1 {
|
||||
if inf1.IsDir() {
|
||||
l1 := inf1.Name()
|
||||
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Loop over level 2 directories
|
||||
for _, inf2 := range infos2 {
|
||||
if inf2.IsDir() {
|
||||
l2 := inf2.Name()
|
||||
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Loop over mailboxes
|
||||
for _, inf3 := range infos3 {
|
||||
if inf3.IsDir() {
|
||||
mbdir := inf3.Name()
|
||||
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
|
||||
idx := filepath.Join(mbpath, INDEX_FILE)
|
||||
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
|
||||
indexPath: idx}
|
||||
mailboxes = append(mailboxes, mb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mailboxes, nil
|
||||
}
|
||||
|
||||
// A Mailbox manages the mail for a specific user and correlates to a particular
|
||||
// directory on disk.
|
||||
type FileMailbox struct {
|
||||
store *FileDataStore
|
||||
name string
|
||||
dirName string
|
||||
path string
|
||||
indexLoaded bool
|
||||
indexPath string
|
||||
messages []*FileMessage
|
||||
}
|
||||
|
||||
func (mb *FileMailbox) String() string {
|
||||
return mb.name + "[" + mb.dirName + "]"
|
||||
}
|
||||
|
||||
// GetMessages scans the mailbox directory for .gob files and decodes them into
|
||||
// a slice of Message objects.
|
||||
func (mb *FileMailbox) GetMessages() ([]Message, error) {
|
||||
if !mb.indexLoaded {
|
||||
if err := mb.readIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
messages := make([]Message, len(mb.messages))
|
||||
for i, m := range mb.messages {
|
||||
messages[i] = m
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetMessage decodes a single message by Id and returns a Message object
|
||||
func (mb *FileMailbox) GetMessage(id string) (Message, error) {
|
||||
if !mb.indexLoaded {
|
||||
if err := mb.readIndex(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range mb.messages {
|
||||
if m.Fid == id {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Message %s not in index", id)
|
||||
}
|
||||
|
||||
// Delete all messages in this mailbox
|
||||
func (mb *FileMailbox) Purge() error {
|
||||
mb.messages = mb.messages[:0]
|
||||
return mb.writeIndex()
|
||||
}
|
||||
|
||||
// readIndex loads the mailbox index data from disk
|
||||
func (mb *FileMailbox) readIndex() error {
|
||||
// Clear message slice, open index
|
||||
mb.messages = mb.messages[:0]
|
||||
// Lock for reading
|
||||
indexLock.RLock()
|
||||
defer indexLock.RUnlock()
|
||||
// Check if index exists
|
||||
if _, err := os.Stat(mb.indexPath); err != nil {
|
||||
// Does not exist, but that's not an error in our world
|
||||
log.LogTrace("Index %v does not exist (yet)", mb.indexPath)
|
||||
mb.indexLoaded = true
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(mb.indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Decode gob data
|
||||
dec := gob.NewDecoder(bufio.NewReader(file))
|
||||
for {
|
||||
// TODO Detect EOF
|
||||
msg := new(FileMessage)
|
||||
if err = dec.Decode(msg); err != nil {
|
||||
if err == io.EOF {
|
||||
// It's OK to get an EOF here
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("While decoding message: %v", err)
|
||||
}
|
||||
msg.mailbox = mb
|
||||
log.LogTrace("Found: %v", msg)
|
||||
mb.messages = append(mb.messages, msg)
|
||||
}
|
||||
|
||||
mb.indexLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDir checks for the presence of the path for this mailbox, creates it if needed
|
||||
func (mb *FileMailbox) createDir() error {
|
||||
if _, err := os.Stat(mb.path); err != nil {
|
||||
if err := os.MkdirAll(mb.path, 0770); err != nil {
|
||||
log.LogError("Failed to create directory %v, %v", mb.path, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIndex overwrites the index on disk with the current mailbox data
|
||||
func (mb *FileMailbox) writeIndex() error {
|
||||
// Lock for writing
|
||||
indexLock.Lock()
|
||||
defer indexLock.Unlock()
|
||||
if len(mb.messages) > 0 {
|
||||
// Ensure mailbox directory exists
|
||||
if err := mb.createDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Open index for writing
|
||||
file, err := os.Create(mb.indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
// Write each message and then flush
|
||||
enc := gob.NewEncoder(writer)
|
||||
for _, m := range mb.messages {
|
||||
err = enc.Encode(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
} else {
|
||||
// No messages, delete index+maildir
|
||||
log.LogTrace("Removing mailbox %v", mb.path)
|
||||
return os.RemoveAll(mb.path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Message contains a little bit of data about a particular email message, and
|
||||
// methods to retrieve the rest of it from disk.
|
||||
type FileMessage struct {
|
||||
mailbox *FileMailbox
|
||||
// Stored in GOB
|
||||
Fid string
|
||||
Fdate time.Time
|
||||
Ffrom string
|
||||
Fsubject string
|
||||
Fsize int64
|
||||
// These are for creating new messages only
|
||||
writable bool
|
||||
writerFile *os.File
|
||||
writer *bufio.Writer
|
||||
}
|
||||
|
||||
// NewMessage creates a new Message object and sets the Date and Id fields.
|
||||
func (mb *FileMailbox) NewMessage() Message {
|
||||
date := time.Now()
|
||||
id := generateId(date)
|
||||
|
||||
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}
|
||||
}
|
||||
|
||||
func (m *FileMessage) Id() string {
|
||||
return m.Fid
|
||||
}
|
||||
|
||||
func (m *FileMessage) Date() time.Time {
|
||||
return m.Fdate
|
||||
}
|
||||
|
||||
func (m *FileMessage) From() string {
|
||||
return m.Ffrom
|
||||
}
|
||||
|
||||
func (m *FileMessage) Subject() string {
|
||||
return m.Fsubject
|
||||
}
|
||||
|
||||
func (m *FileMessage) String() string {
|
||||
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
|
||||
}
|
||||
|
||||
func (m *FileMessage) Size() int64 {
|
||||
return m.Fsize
|
||||
}
|
||||
|
||||
func (m *FileMessage) rawPath() string {
|
||||
return filepath.Join(m.mailbox.path, m.Fid+".raw")
|
||||
}
|
||||
|
||||
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
|
||||
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
|
||||
file, err := os.Open(m.rawPath())
|
||||
defer file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := bufio.NewReader(file)
|
||||
msg, err = mail.ReadMessage(reader)
|
||||
return msg, err
|
||||
}
|
||||
|
||||
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
|
||||
func (m *FileMessage) ReadBody() (body *enmime.MIMEBody, err error) {
|
||||
file, err := os.Open(m.rawPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
reader := bufio.NewReader(file)
|
||||
msg, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mime, err := enmime.ParseMIMEBody(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mime, err
|
||||
}
|
||||
|
||||
// RawReader opens the .raw portion of a Message as an io.ReadCloser
|
||||
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
|
||||
file, err := os.Open(m.rawPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// ReadRaw opens the .raw portion of a Message and returns it as a string
|
||||
func (m *FileMessage) ReadRaw() (raw *string, err error) {
|
||||
reader, err := m.RawReader()
|
||||
defer reader.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyString := string(bodyBytes)
|
||||
return &bodyString, nil
|
||||
}
|
||||
|
||||
// Append data to a newly opened Message, this will fail on a pre-existing Message and
|
||||
// after Close() is called.
|
||||
func (m *FileMessage) Append(data []byte) error {
|
||||
// Prevent Appending to a pre-existing Message
|
||||
if !m.writable {
|
||||
return ErrNotWritable
|
||||
}
|
||||
// Open file for writing if we haven't yet
|
||||
if m.writer == nil {
|
||||
// Ensure mailbox directory exists
|
||||
if err := m.mailbox.createDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(m.rawPath())
|
||||
if err != nil {
|
||||
// Set writable false just in case something calls me a million times
|
||||
m.writable = false
|
||||
return err
|
||||
}
|
||||
m.writerFile = file
|
||||
m.writer = bufio.NewWriter(file)
|
||||
}
|
||||
_, err := m.writer.Write(data)
|
||||
m.Fsize += int64(len(data))
|
||||
return err
|
||||
}
|
||||
|
||||
// Close this Message for writing - no more data may be Appended. Close() will also
|
||||
// trigger the creation of the .gob file.
|
||||
func (m *FileMessage) Close() error {
|
||||
// nil out the writer fields so they can't be used
|
||||
writer := m.writer
|
||||
writerFile := m.writerFile
|
||||
m.writer = nil
|
||||
m.writerFile = nil
|
||||
|
||||
if writer != nil {
|
||||
if err := writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if writerFile != nil {
|
||||
if err := writerFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch headers
|
||||
body, err := m.ReadBody()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only public fields are stored in gob
|
||||
m.Ffrom = body.GetHeader("From")
|
||||
m.Fsubject = body.GetHeader("Subject")
|
||||
|
||||
// Refresh the index before adding our message
|
||||
err = m.mailbox.readIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Made it this far without errors, add it to the index
|
||||
m.mailbox.messages = append(m.mailbox.messages, m)
|
||||
return m.mailbox.writeIndex()
|
||||
}
|
||||
|
||||
// Delete this Message from disk by removing both the gob and raw files
|
||||
func (m *FileMessage) Delete() error {
|
||||
messages := m.mailbox.messages
|
||||
for i, mm := range messages {
|
||||
if m == mm {
|
||||
// Slice around message we are deleting
|
||||
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
m.mailbox.writeIndex()
|
||||
|
||||
if len(m.mailbox.messages) == 0 {
|
||||
// This was the last message, writeIndex() has removed the entire
|
||||
// directory
|
||||
return nil
|
||||
}
|
||||
|
||||
// There are still messages in the index
|
||||
log.LogTrace("Deleting %v", m.rawPath())
|
||||
return os.Remove(m.rawPath())
|
||||
}
|
||||
|
||||
// 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.
|
||||
func generatePrefix(date time.Time) string {
|
||||
return date.Format("20060102T150405")
|
||||
}
|
||||
|
||||
// generateId adds a 4-digit unique number onto the end of the string
|
||||
// returned by generatePrefix()
|
||||
func generateId(date time.Time) string {
|
||||
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
|
||||
}
|
||||
232
smtpd/handler.go
232
smtpd/handler.go
@@ -5,25 +5,37 @@ import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/stringutil"
|
||||
)
|
||||
|
||||
// State tracks the current mode of our SMTP state machine
|
||||
type State int
|
||||
|
||||
const (
|
||||
GREET State = iota // Waiting for HELO
|
||||
READY // Got HELO, waiting for MAIL
|
||||
MAIL // Got MAIL, accepting RCPTs
|
||||
DATA // Got DATA, waiting for "."
|
||||
QUIT // Close session
|
||||
// GREET State: Waiting for HELO
|
||||
GREET State = iota
|
||||
// READY State: Got HELO, waiting for MAIL
|
||||
READY
|
||||
// MAIL State: Got MAIL, accepting RCPTs
|
||||
MAIL
|
||||
// DATA State: Got DATA, waiting for "."
|
||||
DATA
|
||||
// QUIT State: Client requested end of session
|
||||
QUIT
|
||||
)
|
||||
|
||||
const timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case GREET:
|
||||
@@ -58,18 +70,27 @@ var commands = map[string]bool{
|
||||
"TURN": true,
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
server *Server
|
||||
id int
|
||||
conn net.Conn
|
||||
remoteHost string
|
||||
sendError error
|
||||
state State
|
||||
reader *bufio.Reader
|
||||
from string
|
||||
recipients *list.List
|
||||
// recipientDetails for message delivery
|
||||
type recipientDetails struct {
|
||||
address, localPart, domainPart string
|
||||
mailbox datastore.Mailbox
|
||||
}
|
||||
|
||||
// Session holds the state of an SMTP session
|
||||
type Session struct {
|
||||
server *Server
|
||||
id int
|
||||
conn net.Conn
|
||||
remoteDomain string
|
||||
remoteHost string
|
||||
sendError error
|
||||
state State
|
||||
reader *bufio.Reader
|
||||
from string
|
||||
recipients *list.List
|
||||
}
|
||||
|
||||
// NewSession creates a new Session for the given connection
|
||||
func NewSession(server *Server, id int, conn net.Conn) *Session {
|
||||
reader := bufio.NewReader(conn)
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
@@ -88,10 +109,12 @@ func (ss *Session) String() string {
|
||||
* 5. Goto 2
|
||||
*/
|
||||
func (s *Server) startSession(id int, conn net.Conn) {
|
||||
log.LogInfo("SMTP Connection from %v, starting session <%v>", conn.RemoteAddr(), id)
|
||||
log.Infof("SMTP Connection from %v, starting session <%v>", conn.RemoteAddr(), id)
|
||||
expConnectsCurrent.Add(1)
|
||||
defer func() {
|
||||
conn.Close()
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Errorf("Error closing connection for <%v>: %v", id, err)
|
||||
}
|
||||
s.waitgroup.Done()
|
||||
expConnectsCurrent.Add(-1)
|
||||
}()
|
||||
@@ -196,9 +219,21 @@ func (s *Server) startSession(id int, conn net.Conn) {
|
||||
func (ss *Session) greetHandler(cmd string, arg string) {
|
||||
switch cmd {
|
||||
case "HELO":
|
||||
domain, err := parseHelloArgument(arg)
|
||||
if err != nil {
|
||||
ss.send("501 Domain/address argument required for HELO")
|
||||
return
|
||||
}
|
||||
ss.remoteDomain = domain
|
||||
ss.send("250 Great, let's get this show on the road")
|
||||
ss.enterState(READY)
|
||||
case "EHLO":
|
||||
domain, err := parseHelloArgument(arg)
|
||||
if err != nil {
|
||||
ss.send("501 Domain/address argument required for EHLO")
|
||||
return
|
||||
}
|
||||
ss.remoteDomain = domain
|
||||
ss.send("250-Great, let's get this show on the road")
|
||||
ss.send("250-8BITMIME")
|
||||
ss.send(fmt.Sprintf("250 SIZE %v", ss.server.maxMessageBytes))
|
||||
@@ -208,6 +243,17 @@ func (ss *Session) greetHandler(cmd string, arg string) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseHelloArgument(arg string) (string, error) {
|
||||
domain := arg
|
||||
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
|
||||
domain = arg[:idx]
|
||||
}
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("Invalid domain")
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
// READY state -> waiting for MAIL
|
||||
func (ss *Session) readyHandler(cmd string, arg string) {
|
||||
if cmd == "MAIL" {
|
||||
@@ -221,7 +267,7 @@ func (ss *Session) readyHandler(cmd string, arg string) {
|
||||
return
|
||||
}
|
||||
from := m[1]
|
||||
if _, _, err := ParseEmailAddress(from); err != nil {
|
||||
if _, _, err := stringutil.ParseEmailAddress(from); err != nil {
|
||||
ss.send("501 Bad sender address syntax")
|
||||
ss.logWarn("Bad address as MAIL arg: %q, %s", from, err)
|
||||
return
|
||||
@@ -270,7 +316,7 @@ func (ss *Session) mailHandler(cmd string, arg string) {
|
||||
}
|
||||
// This trim is probably too forgiving
|
||||
recip := strings.Trim(arg[3:], "<> ")
|
||||
if _, _, err := ParseEmailAddress(recip); err != nil {
|
||||
if _, _, err := stringutil.ParseEmailAddress(recip); err != nil {
|
||||
ss.send("501 Bad recipient address syntax")
|
||||
ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err)
|
||||
return
|
||||
@@ -294,27 +340,23 @@ func (ss *Session) mailHandler(cmd string, arg string) {
|
||||
// We have recipients, go to accept data
|
||||
ss.enterState(DATA)
|
||||
return
|
||||
} else {
|
||||
// DATA out of sequence
|
||||
ss.ooSeq(cmd)
|
||||
return
|
||||
}
|
||||
// DATA out of sequence
|
||||
ss.ooSeq(cmd)
|
||||
return
|
||||
}
|
||||
ss.ooSeq(cmd)
|
||||
}
|
||||
|
||||
// DATA
|
||||
func (ss *Session) dataHandler() {
|
||||
msgSize := 0
|
||||
|
||||
recipients := make([]recipientDetails, 0, ss.recipients.Len())
|
||||
// Get a Mailbox and a new Message for each recipient
|
||||
mailboxes := make([]Mailbox, ss.recipients.Len())
|
||||
messages := make([]Message, ss.recipients.Len())
|
||||
msgSize := 0
|
||||
if ss.server.storeMessages {
|
||||
i := 0
|
||||
for e := ss.recipients.Front(); e != nil; e = e.Next() {
|
||||
recip := e.Value.(string)
|
||||
local, domain, err := ParseEmailAddress(recip)
|
||||
local, domain, err := stringutil.ParseEmailAddress(recip)
|
||||
if err != nil {
|
||||
ss.logError("Failed to parse address for %q", recip)
|
||||
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip))
|
||||
@@ -325,25 +367,24 @@ func (ss *Session) dataHandler() {
|
||||
// Not our "no store" domain, so store the message
|
||||
mb, err := ss.server.dataStore.MailboxFor(local)
|
||||
if err != nil {
|
||||
ss.logError("Failed to open mailbox for %q", local)
|
||||
ss.logError("Failed to open mailbox for %q: %s", local, err)
|
||||
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", local))
|
||||
ss.reset()
|
||||
return
|
||||
}
|
||||
mailboxes[i] = mb
|
||||
messages[i] = mb.NewMessage()
|
||||
recipients = append(recipients, recipientDetails{recip, local, domain, mb})
|
||||
} else {
|
||||
log.LogTrace("Not storing message for %q", recip)
|
||||
log.Tracef("Not storing message for %q", recip)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
ss.send("354 Start mail input; end with <CRLF>.<CRLF>")
|
||||
var buf bytes.Buffer
|
||||
var lineBuf bytes.Buffer
|
||||
msgBuf := make([][]byte, 0, 1024)
|
||||
for {
|
||||
buf.Reset()
|
||||
err := ss.readByteLine(&buf)
|
||||
lineBuf.Reset()
|
||||
err := ss.readByteLine(&lineBuf)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
if netErr.Timeout() {
|
||||
@@ -354,17 +395,20 @@ func (ss *Session) dataHandler() {
|
||||
ss.enterState(QUIT)
|
||||
return
|
||||
}
|
||||
line := buf.Bytes()
|
||||
if string(line) == ".\r\n" {
|
||||
line := lineBuf.Bytes()
|
||||
// ss.logTrace("DATA: %q", line)
|
||||
if string(line) == ".\r\n" || string(line) == ".\n" {
|
||||
// Mail data complete
|
||||
if ss.server.storeMessages {
|
||||
for _, m := range messages {
|
||||
if m != nil {
|
||||
if err := m.Close(); err != nil {
|
||||
ss.logError("Error: %v while writing message", err)
|
||||
// TODO Report to client?
|
||||
}
|
||||
// Create a message for each valid recipient
|
||||
for _, r := range recipients {
|
||||
if ok := ss.deliverMessage(r, msgBuf); ok {
|
||||
expReceivedTotal.Add(1)
|
||||
} else {
|
||||
// Delivery failure
|
||||
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
|
||||
ss.reset()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -379,30 +423,63 @@ func (ss *Session) dataHandler() {
|
||||
if len(line) > 0 && line[0] == '.' {
|
||||
line = line[1:]
|
||||
}
|
||||
// Second append copies line/lineBuf so we can reuse it
|
||||
msgBuf = append(msgBuf, append([]byte{}, line...))
|
||||
msgSize += len(line)
|
||||
if msgSize > ss.server.maxMessageBytes {
|
||||
// Max message size exceeded
|
||||
ss.send("552 Maximum message size exceeded")
|
||||
ss.logWarn("Max message size exceeded while in DATA")
|
||||
ss.reset()
|
||||
// TODO: Should really cleanup the crap on filesystem...
|
||||
// Should really cleanup the crap on filesystem (after issue #23)
|
||||
return
|
||||
}
|
||||
// Append to message objects
|
||||
if ss.server.storeMessages {
|
||||
for i, m := range messages {
|
||||
if m != nil {
|
||||
if err := m.Append(line); err != nil {
|
||||
ss.logError("Failed to append to mailbox %v: %v", mailboxes[i], err)
|
||||
ss.send("554 Something went wrong")
|
||||
ss.reset()
|
||||
// TODO: Should really cleanup the crap on filesystem...
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end for
|
||||
}
|
||||
|
||||
// deliverMessage creates and populates a new Message for the specified recipient
|
||||
func (ss *Session) deliverMessage(r recipientDetails, msgBuf [][]byte) (ok bool) {
|
||||
msg, err := r.mailbox.NewMessage()
|
||||
if err != nil {
|
||||
ss.logError("Failed to create message for %q: %s", r.localPart, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Generate Received header
|
||||
stamp := time.Now().Format(timeStampFormat)
|
||||
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
ss.remoteDomain, ss.remoteHost, ss.server.domain, r.address, stamp)
|
||||
if err := msg.Append([]byte(recd)); err != nil {
|
||||
ss.logError("Failed to write received header for %q: %s", r.localPart, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Append lines from msgBuf
|
||||
for _, line := range msgBuf {
|
||||
if err := msg.Append(line); err != nil {
|
||||
ss.logError("Failed to append to mailbox %v: %v", r.mailbox, err)
|
||||
// Should really cleanup the crap on filesystem
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := msg.Close(); err != nil {
|
||||
ss.logError("Error while closing message for %v: %v", r.mailbox, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Broadcast message information
|
||||
broadcast := msghub.Message{
|
||||
Mailbox: r.mailbox.Name(),
|
||||
ID: msg.ID(),
|
||||
From: msg.From(),
|
||||
To: msg.To(),
|
||||
Subject: msg.Subject(),
|
||||
Date: msg.Date(),
|
||||
Size: msg.Size(),
|
||||
}
|
||||
ss.server.msgHub.Dispatch(broadcast)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (ss *Session) enterState(state State) {
|
||||
@@ -435,29 +512,16 @@ func (ss *Session) send(msg string) {
|
||||
|
||||
// readByteLine reads a line of input into the provided buffer. Does
|
||||
// not reset the Buffer - please do so prior to calling.
|
||||
func (ss *Session) readByteLine(buf *bytes.Buffer) error {
|
||||
func (ss *Session) readByteLine(buf io.Writer) error {
|
||||
if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
line, err := ss.reader.ReadBytes('\r')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(line)
|
||||
// Read the next byte looking for '\n'
|
||||
c, err := ss.reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.WriteByte(c)
|
||||
if c == '\n' {
|
||||
// We've reached the end of the line, return
|
||||
return nil
|
||||
}
|
||||
// Else, keep looking
|
||||
line, err := ss.reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Should be unreachable
|
||||
_, err = buf.Write(line)
|
||||
return err
|
||||
}
|
||||
|
||||
// Reads a line of input
|
||||
@@ -506,7 +570,7 @@ func (ss *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||
// The leading space is mandatory.
|
||||
func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
||||
args = make(map[string]string)
|
||||
re := regexp.MustCompile(" (\\w+)=(\\w+)")
|
||||
re := regexp.MustCompile(` (\w+)=(\w+)`)
|
||||
pm := re.FindAllStringSubmatch(arg, -1)
|
||||
if pm == nil {
|
||||
ss.logWarn("Failed to parse arg string: %q")
|
||||
@@ -532,21 +596,21 @@ func (ss *Session) ooSeq(cmd string) {
|
||||
|
||||
// Session specific logging methods
|
||||
func (ss *Session) logTrace(msg string, args ...interface{}) {
|
||||
log.LogTrace("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
log.Tracef("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (ss *Session) logInfo(msg string, args ...interface{}) {
|
||||
log.LogInfo("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
log.Infof("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (ss *Session) logWarn(msg string, args ...interface{}) {
|
||||
// Update metrics
|
||||
expWarnsTotal.Add(1)
|
||||
log.LogWarn("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
log.Warnf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
func (ss *Session) logError(msg string, args ...interface{}) {
|
||||
// Update metrics
|
||||
expErrorsTotal.Add(1)
|
||||
log.LogError("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
log.Errorf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ package smtpd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"io"
|
||||
//"io/ioutil"
|
||||
|
||||
"log"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
@@ -22,17 +26,15 @@ type scriptStep struct {
|
||||
// Test commands in GREET state
|
||||
func TestGreetState(t *testing.T) {
|
||||
// Setup mock objects
|
||||
mds := &MockDataStore{}
|
||||
mb1 := &MockMailbox{}
|
||||
mds.On("MailboxFor").Return(mb1, nil)
|
||||
mds := &datastore.MockDataStore{}
|
||||
|
||||
server, logbuf := setupSmtpServer(mds)
|
||||
defer teardownSmtpServer(server)
|
||||
|
||||
var script []scriptStep
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
|
||||
// Test out some mangled HELOs
|
||||
script = []scriptStep{
|
||||
script := []scriptStep{
|
||||
{"HELO", 501},
|
||||
{"EHLO", 501},
|
||||
{"HELLO", 500},
|
||||
{"HELL", 500},
|
||||
{"hello", 500},
|
||||
@@ -43,9 +45,6 @@ func TestGreetState(t *testing.T) {
|
||||
}
|
||||
|
||||
// Valid HELOs
|
||||
if err := playSession(t, server, []scriptStep{{"HELO", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
@@ -55,29 +54,42 @@ func TestGreetState(t *testing.T) {
|
||||
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Valid EHLOs
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if t.Failed() {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in READY state
|
||||
func TestReadyState(t *testing.T) {
|
||||
// Setup mock objects
|
||||
mds := &MockDataStore{}
|
||||
mb1 := &MockMailbox{}
|
||||
mds.On("MailboxFor").Return(mb1, nil)
|
||||
mds := &datastore.MockDataStore{}
|
||||
|
||||
server, logbuf := setupSmtpServer(mds)
|
||||
defer teardownSmtpServer(server)
|
||||
|
||||
var script []scriptStep
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
|
||||
// Test out some mangled READY commands
|
||||
script = []scriptStep{
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"FOOB", 500},
|
||||
{"HELO", 503},
|
||||
@@ -125,27 +137,32 @@ func TestReadyState(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in MAIL state
|
||||
func TestMailState(t *testing.T) {
|
||||
// Setup mock objects
|
||||
mds := &MockDataStore{}
|
||||
mb1 := &MockMailbox{}
|
||||
msg1 := &MockMessage{}
|
||||
mds.On("MailboxFor").Return(mb1, nil)
|
||||
mb1.On("NewMessage").Return(msg1)
|
||||
mds := &datastore.MockDataStore{}
|
||||
mb1 := &datastore.MockMailbox{}
|
||||
msg1 := &datastore.MockMessage{}
|
||||
mds.On("MailboxFor", "u1").Return(mb1, nil)
|
||||
mb1.On("NewMessage").Return(msg1, nil)
|
||||
mb1.On("Name").Return("u1")
|
||||
msg1.On("ID").Return("")
|
||||
msg1.On("From").Return("")
|
||||
msg1.On("To").Return(make([]string, 0))
|
||||
msg1.On("Date").Return(time.Time{})
|
||||
msg1.On("Subject").Return("")
|
||||
msg1.On("Size").Return(0)
|
||||
msg1.On("Close").Return(nil)
|
||||
|
||||
server, logbuf := setupSmtpServer(mds)
|
||||
defer teardownSmtpServer(server)
|
||||
|
||||
var script []scriptStep
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
|
||||
// Test out some mangled READY commands
|
||||
script = []scriptStep{
|
||||
script := []scriptStep{
|
||||
{"HELO localhost", 250},
|
||||
{"MAIL FROM:<john@gmail.com>", 250},
|
||||
{"FOOB", 500},
|
||||
@@ -235,25 +252,32 @@ func TestMailState(t *testing.T) {
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// Test commands in DATA state
|
||||
func TestDataState(t *testing.T) {
|
||||
// Setup mock objects
|
||||
mds := &MockDataStore{}
|
||||
mb1 := &MockMailbox{}
|
||||
msg1 := &MockMessage{}
|
||||
mds.On("MailboxFor").Return(mb1, nil)
|
||||
mb1.On("NewMessage").Return(msg1)
|
||||
mds := &datastore.MockDataStore{}
|
||||
mb1 := &datastore.MockMailbox{}
|
||||
msg1 := &datastore.MockMessage{}
|
||||
mds.On("MailboxFor", "u1").Return(mb1, nil)
|
||||
mb1.On("NewMessage").Return(msg1, nil)
|
||||
mb1.On("Name").Return("u1")
|
||||
msg1.On("ID").Return("")
|
||||
msg1.On("From").Return("")
|
||||
msg1.On("To").Return(make([]string, 0))
|
||||
msg1.On("Date").Return(time.Time{})
|
||||
msg1.On("Subject").Return("")
|
||||
msg1.On("Size").Return(0)
|
||||
msg1.On("Close").Return(nil)
|
||||
|
||||
server, logbuf := setupSmtpServer(mds)
|
||||
defer teardownSmtpServer(server)
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
|
||||
var script []scriptStep
|
||||
pipe := setupSmtpSession(server)
|
||||
pipe := setupSMTPSession(server)
|
||||
c := textproto.NewConn(pipe)
|
||||
|
||||
// Get us into DATA state
|
||||
@@ -277,8 +301,8 @@ Subject: test
|
||||
Hi!
|
||||
`
|
||||
dw := c.DotWriter()
|
||||
io.WriteString(dw, body)
|
||||
dw.Close()
|
||||
_, _ = io.WriteString(dw, body)
|
||||
_ = dw.Close()
|
||||
if code, _, err := c.ReadCodeLine(250); err != nil {
|
||||
t.Errorf("Expected a 250 greeting, got %v", code)
|
||||
}
|
||||
@@ -287,13 +311,13 @@ Hi!
|
||||
// Wait for handler to finish logging
|
||||
time.Sleep(2 * time.Second)
|
||||
// Dump buffered log data if there was a failure
|
||||
io.Copy(os.Stderr, logbuf)
|
||||
_, _ = io.Copy(os.Stderr, logbuf)
|
||||
}
|
||||
}
|
||||
|
||||
// playSession creates a new session, reads the greeting and then plays the script
|
||||
func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
||||
pipe := setupSmtpSession(server)
|
||||
pipe := setupSMTPSession(server)
|
||||
c := textproto.NewConn(pipe)
|
||||
|
||||
if code, _, err := c.ReadCodeLine(220); err != nil {
|
||||
@@ -302,8 +326,10 @@ func playSession(t *testing.T, server *Server, script []scriptStep) error {
|
||||
|
||||
err := playScriptAgainst(t, c, script)
|
||||
|
||||
c.Cmd("QUIT")
|
||||
c.ReadCodeLine(221)
|
||||
// Not all tests leave the session in a clean state, so the following two
|
||||
// calls can fail
|
||||
_, _ = c.Cmd("QUIT")
|
||||
_, _, _ = c.ReadCodeLine(221)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -317,7 +343,7 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
|
||||
}
|
||||
|
||||
c.StartResponse(id)
|
||||
code, msg, err := c.ReadCodeLine(step.expect)
|
||||
code, msg, err := c.ReadResponse(step.expect)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Step %d, sent %q, expected %v, got %v: %q",
|
||||
i, step.send, step.expect, code, msg)
|
||||
@@ -341,11 +367,11 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func setupSmtpServer(ds DataStore) (*Server, *bytes.Buffer) {
|
||||
func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
|
||||
// Test Server Config
|
||||
cfg := config.SmtpConfig{
|
||||
Ip4address: net.IPv4(127, 0, 0, 1),
|
||||
Ip4port: 2500,
|
||||
cfg := config.SMTPConfig{
|
||||
IP4address: net.IPv4(127, 0, 0, 1),
|
||||
IP4port: 2500,
|
||||
Domain: "inbucket.local",
|
||||
DomainNoStore: "bitbucket.local",
|
||||
MaxRecipients: 5,
|
||||
@@ -355,16 +381,23 @@ func setupSmtpServer(ds DataStore) (*Server, *bytes.Buffer) {
|
||||
}
|
||||
|
||||
// Capture log output
|
||||
buf := new(bytes.Buffer)
|
||||
buf = new(bytes.Buffer)
|
||||
log.SetOutput(buf)
|
||||
|
||||
// Create a server, don't start it
|
||||
return NewSmtpServer(cfg, ds), buf
|
||||
shutdownChan := make(chan bool)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
teardown = func() {
|
||||
close(shutdownChan)
|
||||
cancel()
|
||||
}
|
||||
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
|
||||
return s, buf, teardown
|
||||
}
|
||||
|
||||
var sessionNum int
|
||||
|
||||
func setupSmtpSession(server *Server) net.Conn {
|
||||
func setupSMTPSession(server *Server) net.Conn {
|
||||
// Pair of pipes to communicate
|
||||
serverConn, clientConn := net.Pipe()
|
||||
// Start the session
|
||||
@@ -374,7 +407,3 @@ func setupSmtpSession(server *Server) net.Conn {
|
||||
|
||||
return clientConn
|
||||
}
|
||||
|
||||
func teardownSmtpServer(server *Server) {
|
||||
//log.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
@@ -2,158 +2,20 @@ package smtpd
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
// Real server code starts here
|
||||
type Server struct {
|
||||
domain string
|
||||
domainNoStore string
|
||||
maxRecips int
|
||||
maxIdleSeconds int
|
||||
maxMessageBytes int
|
||||
dataStore DataStore
|
||||
storeMessages bool
|
||||
listener net.Listener
|
||||
shutdown bool
|
||||
waitgroup *sync.WaitGroup
|
||||
}
|
||||
|
||||
// Raw stat collectors
|
||||
var expConnectsTotal = new(expvar.Int)
|
||||
var expConnectsCurrent = new(expvar.Int)
|
||||
var expReceivedTotal = new(expvar.Int)
|
||||
var expErrorsTotal = new(expvar.Int)
|
||||
var expWarnsTotal = new(expvar.Int)
|
||||
|
||||
// History of certain stats
|
||||
var deliveredHist = list.New()
|
||||
var connectsHist = list.New()
|
||||
var errorsHist = list.New()
|
||||
var warnsHist = list.New()
|
||||
|
||||
// History rendered as comma delim string
|
||||
var expReceivedHist = new(expvar.String)
|
||||
var expConnectsHist = new(expvar.String)
|
||||
var expErrorsHist = new(expvar.String)
|
||||
var expWarnsHist = new(expvar.String)
|
||||
|
||||
// Init a new Server object
|
||||
func NewSmtpServer(cfg config.SmtpConfig, ds DataStore) *Server {
|
||||
return &Server{dataStore: ds, domain: cfg.Domain, maxRecips: cfg.MaxRecipients,
|
||||
maxIdleSeconds: cfg.MaxIdleSeconds, maxMessageBytes: cfg.MaxMessageBytes,
|
||||
storeMessages: cfg.StoreMessages, domainNoStore: strings.ToLower(cfg.DomainNoStore),
|
||||
waitgroup: new(sync.WaitGroup)}
|
||||
}
|
||||
|
||||
// Main listener loop
|
||||
func (s *Server) Start() {
|
||||
cfg := config.GetSmtpConfig()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
|
||||
cfg.Ip4address, cfg.Ip4port))
|
||||
if err != nil {
|
||||
log.LogError("Failed to build tcp4 address: %v", err)
|
||||
// TODO More graceful early-shutdown procedure
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.LogInfo("SMTP listening on TCP4 %v", addr)
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
log.LogError("SMTP failed to start tcp4 listener: %v", err)
|
||||
// TODO More graceful early-shutdown procedure
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if !s.storeMessages {
|
||||
log.LogInfo("Load test mode active, messages will not be stored")
|
||||
} else if s.domainNoStore != "" {
|
||||
log.LogInfo("Messages sent to domain '%v' will be discarded", s.domainNoStore)
|
||||
}
|
||||
|
||||
// Start retention scanner
|
||||
StartRetentionScanner(s.dataStore)
|
||||
|
||||
// Handle incoming connections
|
||||
var tempDelay time.Duration
|
||||
for sid := 1; ; sid++ {
|
||||
if conn, err := s.listener.Accept(); err != nil {
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||
// Temporary error, sleep for a bit and try again
|
||||
if tempDelay == 0 {
|
||||
tempDelay = 5 * time.Millisecond
|
||||
} else {
|
||||
tempDelay *= 2
|
||||
}
|
||||
if max := 1 * time.Second; tempDelay > max {
|
||||
tempDelay = max
|
||||
}
|
||||
log.LogError("SMTP accept error: %v; retrying in %v", err, tempDelay)
|
||||
time.Sleep(tempDelay)
|
||||
continue
|
||||
} else {
|
||||
if s.shutdown {
|
||||
log.LogTrace("SMTP listener shutting down on request")
|
||||
return
|
||||
}
|
||||
// TODO Implement a max error counter before shutdown?
|
||||
// or maybe attempt to restart smtpd
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
tempDelay = 0
|
||||
expConnectsTotal.Add(1)
|
||||
s.waitgroup.Add(1)
|
||||
go s.startSession(sid, conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop requests the SMTP server closes it's listener
|
||||
func (s *Server) Stop() {
|
||||
log.LogTrace("SMTP shutdown requested, connections will be drained")
|
||||
s.shutdown = true
|
||||
s.listener.Close()
|
||||
}
|
||||
|
||||
// Drain causes the caller to block until all active SMTP sessions have finished
|
||||
func (s *Server) Drain() {
|
||||
s.waitgroup.Wait()
|
||||
log.LogTrace("SMTP connections drained")
|
||||
}
|
||||
|
||||
// When the provided Ticker ticks, we update our metrics history
|
||||
func metricsTicker(t *time.Ticker) {
|
||||
ok := true
|
||||
for ok {
|
||||
_, ok = <-t.C
|
||||
expReceivedHist.Set(pushMetric(deliveredHist, expReceivedTotal))
|
||||
expConnectsHist.Set(pushMetric(connectsHist, expConnectsTotal))
|
||||
expErrorsHist.Set(pushMetric(errorsHist, expErrorsTotal))
|
||||
expWarnsHist.Set(pushMetric(warnsHist, expWarnsTotal))
|
||||
expRetentionDeletesHist.Set(pushMetric(retentionDeletesHist, expRetentionDeletesTotal))
|
||||
expRetainedHist.Set(pushMetric(retainedHist, expRetainedCurrent))
|
||||
}
|
||||
}
|
||||
|
||||
// pushMetric adds the metric to the end of the list and returns a comma separated string of the
|
||||
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
|
||||
// tracks deltas between these values - there is nothing to compare the first value against.
|
||||
func pushMetric(history *list.List, ev expvar.Var) string {
|
||||
history.PushBack(ev.String())
|
||||
if history.Len() > 61 {
|
||||
history.Remove(history.Front())
|
||||
}
|
||||
return JoinStringList(history)
|
||||
}
|
||||
|
||||
func init() {
|
||||
m := expvar.NewMap("smtp")
|
||||
m.Set("ConnectsTotal", expConnectsTotal)
|
||||
@@ -166,6 +28,172 @@ func init() {
|
||||
m.Set("WarnsTotal", expWarnsTotal)
|
||||
m.Set("WarnsHist", expWarnsHist)
|
||||
|
||||
t := time.NewTicker(time.Minute)
|
||||
go metricsTicker(t)
|
||||
log.AddTickerFunc(func() {
|
||||
expReceivedHist.Set(log.PushMetric(deliveredHist, expReceivedTotal))
|
||||
expConnectsHist.Set(log.PushMetric(connectsHist, expConnectsTotal))
|
||||
expErrorsHist.Set(log.PushMetric(errorsHist, expErrorsTotal))
|
||||
expWarnsHist.Set(log.PushMetric(warnsHist, expWarnsTotal))
|
||||
})
|
||||
}
|
||||
|
||||
// Server holds the configuration and state of our SMTP server
|
||||
type Server struct {
|
||||
// Configuration
|
||||
host string
|
||||
domain string
|
||||
domainNoStore string
|
||||
maxRecips int
|
||||
maxIdleSeconds int
|
||||
maxMessageBytes int
|
||||
storeMessages bool
|
||||
|
||||
// Dependencies
|
||||
dataStore datastore.DataStore // Mailbox/message store
|
||||
globalShutdown chan bool // Shuts down Inbucket
|
||||
msgHub *msghub.Hub // Pub/sub for message info
|
||||
retentionScanner *datastore.RetentionScanner // Deletes expired messages
|
||||
|
||||
// State
|
||||
listener net.Listener // Incoming network connections
|
||||
waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions
|
||||
}
|
||||
|
||||
var (
|
||||
// Raw stat collectors
|
||||
expConnectsTotal = new(expvar.Int)
|
||||
expConnectsCurrent = new(expvar.Int)
|
||||
expReceivedTotal = new(expvar.Int)
|
||||
expErrorsTotal = new(expvar.Int)
|
||||
expWarnsTotal = new(expvar.Int)
|
||||
|
||||
// History of certain stats
|
||||
deliveredHist = list.New()
|
||||
connectsHist = list.New()
|
||||
errorsHist = list.New()
|
||||
warnsHist = list.New()
|
||||
|
||||
// History rendered as comma delim string
|
||||
expReceivedHist = new(expvar.String)
|
||||
expConnectsHist = new(expvar.String)
|
||||
expErrorsHist = new(expvar.String)
|
||||
expWarnsHist = new(expvar.String)
|
||||
)
|
||||
|
||||
// NewServer creates a new Server instance with the specificed config
|
||||
func NewServer(
|
||||
cfg config.SMTPConfig,
|
||||
globalShutdown chan bool,
|
||||
ds datastore.DataStore,
|
||||
msgHub *msghub.Hub) *Server {
|
||||
return &Server{
|
||||
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
|
||||
domain: cfg.Domain,
|
||||
domainNoStore: strings.ToLower(cfg.DomainNoStore),
|
||||
maxRecips: cfg.MaxRecipients,
|
||||
maxIdleSeconds: cfg.MaxIdleSeconds,
|
||||
maxMessageBytes: cfg.MaxMessageBytes,
|
||||
storeMessages: cfg.StoreMessages,
|
||||
globalShutdown: globalShutdown,
|
||||
dataStore: ds,
|
||||
msgHub: msgHub,
|
||||
retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown),
|
||||
waitgroup: new(sync.WaitGroup),
|
||||
}
|
||||
}
|
||||
|
||||
// Start the listener and handle incoming connections
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
addr, err := net.ResolveTCPAddr("tcp4", s.host)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to build tcp4 address: %v", err)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("SMTP listening on TCP4 %v", addr)
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
log.Errorf("SMTP failed to start tcp4 listener: %v", err)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storeMessages {
|
||||
log.Infof("Load test mode active, messages will not be stored")
|
||||
} else if s.domainNoStore != "" {
|
||||
log.Infof("Messages sent to domain '%v' will be discarded", s.domainNoStore)
|
||||
}
|
||||
|
||||
// Start retention scanner
|
||||
s.retentionScanner.Start()
|
||||
|
||||
// Listener go routine
|
||||
go s.serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
<-ctx.Done()
|
||||
log.Tracef("SMTP shutdown requested, connections will be drained")
|
||||
|
||||
// Closing the listener will cause the serve() go routine to exit
|
||||
if err := s.listener.Close(); err != nil {
|
||||
log.Errorf("Failed to close SMTP listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serve is the listen/accept loop
|
||||
func (s *Server) serve(ctx context.Context) {
|
||||
// Handle incoming connections
|
||||
var tempDelay time.Duration
|
||||
for sessionID := 1; ; sessionID++ {
|
||||
if conn, err := s.listener.Accept(); err != nil {
|
||||
// There was an error accepting the connection
|
||||
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
|
||||
// Temporary error, sleep for a bit and try again
|
||||
if tempDelay == 0 {
|
||||
tempDelay = 5 * time.Millisecond
|
||||
} else {
|
||||
tempDelay *= 2
|
||||
}
|
||||
if max := 1 * time.Second; tempDelay > max {
|
||||
tempDelay = max
|
||||
}
|
||||
log.Errorf("SMTP accept error: %v; retrying in %v", err, tempDelay)
|
||||
time.Sleep(tempDelay)
|
||||
continue
|
||||
} else {
|
||||
// Permanent error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// SMTP is shutting down
|
||||
return
|
||||
default:
|
||||
// Something went wrong
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tempDelay = 0
|
||||
expConnectsTotal.Add(1)
|
||||
s.waitgroup.Add(1)
|
||||
go s.startSession(sessionID, conn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case <-s.globalShutdown:
|
||||
default:
|
||||
close(s.globalShutdown)
|
||||
}
|
||||
}
|
||||
|
||||
// Drain causes the caller to block until all active SMTP sessions have finished
|
||||
func (s *Server) Drain() {
|
||||
// Wait for sessions to close
|
||||
s.waitgroup.Wait()
|
||||
log.Tracef("SMTP connections have drained")
|
||||
s.retentionScanner.Join()
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"expvar"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var retentionScanCompleted time.Time
|
||||
var retentionScanCompletedMu sync.RWMutex
|
||||
|
||||
var expRetentionDeletesTotal = new(expvar.Int)
|
||||
var expRetentionPeriod = new(expvar.Int)
|
||||
var expRetainedCurrent = new(expvar.Int)
|
||||
|
||||
// History of certain stats
|
||||
var retentionDeletesHist = list.New()
|
||||
var retainedHist = list.New()
|
||||
|
||||
// History rendered as comma delim string
|
||||
var expRetentionDeletesHist = new(expvar.String)
|
||||
var expRetainedHist = new(expvar.String)
|
||||
|
||||
func StartRetentionScanner(ds DataStore) {
|
||||
cfg := config.GetDataStoreConfig()
|
||||
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
|
||||
if cfg.RetentionMinutes > 0 {
|
||||
// Retention scanning enabled
|
||||
log.LogInfo("Retention configured for %v minutes", cfg.RetentionMinutes)
|
||||
go retentionScanner(ds, time.Duration(cfg.RetentionMinutes) * time.Minute,
|
||||
time.Duration(cfg.RetentionSleep) * time.Millisecond)
|
||||
} else {
|
||||
log.LogInfo("Retention scanner disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func retentionScanner(ds DataStore, maxAge time.Duration, sleep time.Duration) {
|
||||
start := time.Now()
|
||||
for {
|
||||
// Prevent scanner from running more than once a minute
|
||||
since := time.Since(start)
|
||||
if since < time.Minute {
|
||||
dur := time.Minute - since
|
||||
log.LogTrace("Retention scanner sleeping for %v", dur)
|
||||
time.Sleep(dur)
|
||||
}
|
||||
start = time.Now()
|
||||
|
||||
// Kickoff scan
|
||||
if err := doRetentionScan(ds, maxAge, sleep); err != nil {
|
||||
log.LogError("Error during retention scan: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// doRetentionScan does a single pass of all mailboxes looking for messages that can be purged
|
||||
func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) error {
|
||||
log.LogTrace("Starting retention scan")
|
||||
cutoff := time.Now().Add(-1 * maxAge)
|
||||
mboxes, err := ds.AllMailboxes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
retained := 0
|
||||
for _, mb := range mboxes {
|
||||
messages, err := mb.GetMessages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if msg.Date().Before(cutoff) {
|
||||
log.LogTrace("Purging expired message %v", msg.Id())
|
||||
err = msg.Delete()
|
||||
if err != nil {
|
||||
// Log but don't abort
|
||||
log.LogError("Failed to purge message %v: %v", msg.Id(), err)
|
||||
} else {
|
||||
expRetentionDeletesTotal.Add(1)
|
||||
}
|
||||
} else {
|
||||
retained++
|
||||
}
|
||||
}
|
||||
// Sleep after completing a mailbox
|
||||
time.Sleep(sleep)
|
||||
}
|
||||
|
||||
setRetentionScanCompleted(time.Now())
|
||||
expRetainedCurrent.Set(int64(retained))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setRetentionScanCompleted(t time.Time) {
|
||||
retentionScanCompletedMu.Lock()
|
||||
defer retentionScanCompletedMu.Unlock()
|
||||
|
||||
retentionScanCompleted = t
|
||||
}
|
||||
|
||||
func getRetentionScanCompleted() time.Time {
|
||||
retentionScanCompletedMu.RLock()
|
||||
defer retentionScanCompletedMu.RUnlock()
|
||||
|
||||
return retentionScanCompleted
|
||||
}
|
||||
|
||||
func secondsSinceRetentionScanCompleted() interface{} {
|
||||
return time.Since(getRetentionScanCompleted()) / time.Second
|
||||
}
|
||||
|
||||
func init() {
|
||||
rm := expvar.NewMap("retention")
|
||||
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
|
||||
rm.Set("DeletesHist", expRetentionDeletesHist)
|
||||
rm.Set("DeletesTotal", expRetentionDeletesTotal)
|
||||
rm.Set("Period", expRetentionPeriod)
|
||||
rm.Set("RetainedHist", expRetainedHist)
|
||||
rm.Set("RetainedCurrent", expRetainedCurrent)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
package smtpd
|
||||
package stringutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Take "user+ext" and return "user", aka the mailbox we'll store it in
|
||||
// Return error if it contains invalid characters, we don't accept anything
|
||||
// that must be quoted according to RFC3696.
|
||||
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
|
||||
// and returns just the mailbox name (ex: "user"). Returns an error if
|
||||
// localPart contains invalid characters; it won't accept any that must be
|
||||
// quoted according to RFC3696.
|
||||
func ParseMailboxName(localPart string) (result string, err error) {
|
||||
if localPart == "" {
|
||||
return "", fmt.Errorf("Mailbox name cannot be empty")
|
||||
@@ -20,7 +20,7 @@ func ParseMailboxName(localPart string) (result string, err error) {
|
||||
|
||||
invalid := make([]byte, 0, 10)
|
||||
|
||||
for i := 0; i<len(result); i++ {
|
||||
for i := 0; i < len(result); i++ {
|
||||
c := result[i]
|
||||
switch {
|
||||
case 'a' <= c && c <= 'z':
|
||||
@@ -41,23 +41,15 @@ func ParseMailboxName(localPart string) (result string, err error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Take a mailbox name and hash it into the directory we'll store it in
|
||||
// HashMailboxName accepts a mailbox name and hashes it. filestore uses this as
|
||||
// the directory to house the mailbox
|
||||
func HashMailboxName(mailbox string) string {
|
||||
h := sha1.New()
|
||||
io.WriteString(h, mailbox)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// JoinStringList joins a List containing strings by commas
|
||||
func JoinStringList(listOfStrings *list.List) string {
|
||||
if listOfStrings.Len() == 0 {
|
||||
if _, err := io.WriteString(h, mailbox); err != nil {
|
||||
// This shouldn't ever happen
|
||||
return ""
|
||||
}
|
||||
s := make([]string, 0, listOfStrings.Len())
|
||||
for e := listOfStrings.Front(); e != nil; e = e.Next() {
|
||||
s = append(s, e.Value.(string))
|
||||
}
|
||||
return strings.Join(s, ",")
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
|
||||
@@ -73,15 +65,14 @@ func ValidateDomainPart(domain string) bool {
|
||||
}
|
||||
prev := '.'
|
||||
labelLen := 0
|
||||
hasLetters := false
|
||||
hasAlphaNum := false
|
||||
|
||||
for _, c := range domain {
|
||||
switch {
|
||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || c == '_':
|
||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
|
||||
('0' <= c && c <= '9') || c == '_':
|
||||
// Must contain some of these to be a valid label
|
||||
hasLetters = true
|
||||
labelLen++
|
||||
case '0' <= c && c <= '9':
|
||||
hasAlphaNum = true
|
||||
labelLen++
|
||||
case c == '-':
|
||||
if prev == '.' {
|
||||
@@ -96,11 +87,11 @@ func ValidateDomainPart(domain string) bool {
|
||||
if labelLen > 63 {
|
||||
return false
|
||||
}
|
||||
if !hasLetters {
|
||||
if !hasAlphaNum {
|
||||
return false
|
||||
}
|
||||
labelLen = 0
|
||||
hasLetters = false
|
||||
hasAlphaNum = false
|
||||
default:
|
||||
// Unknown character
|
||||
return false
|
||||
@@ -139,15 +130,24 @@ LOOP:
|
||||
switch {
|
||||
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
|
||||
// Letters are OK
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
case '0' <= c && c <= '9':
|
||||
// Numbers are OK
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
|
||||
// These specials can be used unquoted
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
case c == '.':
|
||||
// A single period is OK
|
||||
@@ -155,13 +155,19 @@ LOOP:
|
||||
// Sequence of periods is not permitted
|
||||
return "", "", fmt.Errorf("Sequence of periods is not permitted")
|
||||
}
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
case c == '\\':
|
||||
inCharQuote = true
|
||||
case c == '"':
|
||||
if inCharQuote {
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
} else if inStringQuote {
|
||||
inStringQuote = false
|
||||
@@ -174,12 +180,15 @@ LOOP:
|
||||
}
|
||||
case c == '@':
|
||||
if inCharQuote || inStringQuote {
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
} else {
|
||||
// End of local-part
|
||||
if i > 63 {
|
||||
return "", "", fmt.Errorf("Local part must not exceed 64 characters")
|
||||
if i > 128 {
|
||||
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
|
||||
}
|
||||
if prev == '.' {
|
||||
return "", "", fmt.Errorf("Local part cannot end with a period")
|
||||
@@ -191,7 +200,10 @@ LOOP:
|
||||
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
|
||||
default:
|
||||
if inCharQuote || inStringQuote {
|
||||
buf.WriteByte(c)
|
||||
err = buf.WriteByte(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
inCharQuote = false
|
||||
} else {
|
||||
return "", "", fmt.Errorf("Character %q must be quoted", c)
|
||||
@@ -1,14 +1,15 @@
|
||||
package smtpd
|
||||
package stringutil
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseMailboxName(t *testing.T) {
|
||||
var validTable = []struct{
|
||||
input string
|
||||
var validTable = []struct {
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{"mailbox", "mailbox"},
|
||||
@@ -33,7 +34,7 @@ func TestParseMailboxName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var invalidTable = []struct{
|
||||
var invalidTable = []struct {
|
||||
input, msg string
|
||||
}{
|
||||
{"", "Empty mailbox name is not permitted"},
|
||||
@@ -74,9 +75,10 @@ func TestValidateDomain(t *testing.T) {
|
||||
{"_domainkey.foo.com", true, "Underscores are allowed"},
|
||||
{"bar.com.", true, "Must be able to end with a dot"},
|
||||
{"ABC.6DBS.com", true, "Mixed case is OK"},
|
||||
{"mail.123.com", true, "Number only label valid"},
|
||||
{"123.com", true, "Number only label valid"},
|
||||
{"google..com", false, "Double dot not valid"},
|
||||
{".foo.com", false, "Cannot start with a dot"},
|
||||
{"mail.123.com", false, "Number only label not valid"},
|
||||
{"google\r.com", false, "Special chars not allowed"},
|
||||
{"foo.-bar.com", false, "Label cannot start with hyphen"},
|
||||
{"foo-.bar.com", false, "Label cannot end with hyphen"},
|
||||
@@ -97,7 +99,8 @@ func TestValidateLocal(t *testing.T) {
|
||||
}{
|
||||
{"", false, "Empty local is not valid"},
|
||||
{"a", true, "Single letter should be fine"},
|
||||
{strings.Repeat("a", 65), false, "Only valid up to 64 characters"},
|
||||
{strings.Repeat("a", 128), true, "Valid up to 128 characters"},
|
||||
{strings.Repeat("a", 129), false, "Only valid up to 128 characters"},
|
||||
{"FirstLast", true, "Mixed case permitted"},
|
||||
{"user123", true, "Numbers permitted"},
|
||||
{"a!#$%&'*+-/=?^_`{|}~", true, "Any of !#$%&'*+-/=?^_`{|}~ are permitted"},
|
||||
30
swaks-tests/README.md
Normal file
30
swaks-tests/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
swaks-tests
|
||||
===========
|
||||
|
||||
[Swaks](http://www.jetmore.org/john/code/swaks/) - Swiss Army Knife for SMTP
|
||||
|
||||
Swaks gives us an easy way to generate mail to send into Inbucket. You will need to
|
||||
install Swaks before you can use the provided scripts.
|
||||
|
||||
## Usage
|
||||
|
||||
To deliver a batch of test email to the `swaks` mailbox, assuming Inbucket SMTP is listening
|
||||
on localhost:2500:
|
||||
|
||||
./run-tests.sh
|
||||
|
||||
To deliver a batch of test email to the `james` mailbox:
|
||||
|
||||
./run-tests.sh james
|
||||
|
||||
You may also pass swaks options to deliver to a alternate host/port:
|
||||
|
||||
./run-tests --server inbucket.mydomain.com:25
|
||||
|
||||
To specify the mailbox with an alternate server, use `--to` with a local and host part:
|
||||
|
||||
./run-tests --server inbucket.mydomain.com:25 --to james@mydomain.com
|
||||
|
||||
## To Do
|
||||
|
||||
Replace Swaks with a native Go solution.
|
||||
213
swaks-tests/gmail.raw
Normal file
213
swaks-tests/gmail.raw
Normal file
@@ -0,0 +1,213 @@
|
||||
MIME-Version: 1.0
|
||||
Date: %DATE%
|
||||
Message-ID: <CANqLHXqq1i6cOTcEHa7W9hT21ZueMJTM4QaP5DH0YXScqYCEuw@mail.gmail.com>
|
||||
Subject: Test from Gmail
|
||||
From: %FROM_ADDRESS%
|
||||
X-ASG-Orig-Subj: Test from Gmail
|
||||
To: %TO_ADDRESS%
|
||||
Content-Type: multipart/mixed; boundary=001a113d2d045cd646051e1383c2
|
||||
X-Barracuda-Spam-Score: 0.50
|
||||
X-Barracuda-Spam-Status: No, SCORE=0.50 using global scores of TAG_LEVEL=2.0 QUARANTINE_LEVEL=1000.0 KILL_LEVEL=5.0 tests=BSF_SC5_SA210e, HTML_MESSAGE, WEIRD_PORT
|
||||
X-Barracuda-Spam-Report: Code version 3.2, rules version 3.2.3.21886
|
||||
Rule breakdown below
|
||||
pts rule name description
|
||||
---- ---------------------- --------------------------------------------------
|
||||
0.50 WEIRD_PORT URI: Uses non-standard port number for HTTP
|
||||
0.00 HTML_MESSAGE BODY: HTML included in message
|
||||
0.00 BSF_SC5_SA210e Custom Rule SA210e
|
||||
|
||||
--001a113d2d045cd646051e1383c2
|
||||
Content-Type: multipart/alternative; boundary=001a113d2d045cd63c051e1383c0
|
||||
|
||||
--001a113d2d045cd63c051e1383c0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer at ex
|
||||
urna. Nunc sollicitudin venenatis dolor, eget convallis libero convallis
|
||||
eu. Nulla luctus ligula in magna condimentum, placerat lacinia nulla
|
||||
varius. Curabitur quis placerat enim, et aliquam ligula. In dignissim
|
||||
lectus vel pharetra posuere. Proin imperdiet augue orci, at dapibus urna
|
||||
sagittis vel. In nec arcu placerat, vehicula est vel, sollicitudin erat.
|
||||
Nunc tempor risus lorem, sed bibendum tellus pretium non. Sed vel tortor
|
||||
ante. Donec convallis erat ac mauris mollis feugiat. Donec pharetra ex ac
|
||||
tempus aliquam. Praesent ut purus tristique, pharetra arcu vulputate,
|
||||
eleifend felis.
|
||||
|
||||
Aenean ut porttitor risus, a porta nunc. Donec ligula diam, sagittis at
|
||||
luctus id, luctus sit amet risus. Sed turpis nisl, fermentum vitae nibh
|
||||
non, imperdiet luctus arcu. Fusce vulputate velit porta, rutrum velit nec,
|
||||
semper eros. Suspendisse in dui non nulla lacinia tincidunt. Vivamus sit
|
||||
amet lectus eu velit condimentum gravida. Curabitur vestibulum felis nisl,
|
||||
a sagittis odio faucibus a. Nulla fermentum, ligula in gravida hendrerit,
|
||||
diam dui tempor dolor, id euismod tortor velit at massa. Phasellus et nunc
|
||||
mi. Integer tristique viverra odio vitae auctor. Lorem ipsum dolor sit
|
||||
amet, consectetur adipiscing elit. Nunc rutrum turpis ornare lorem ornare
|
||||
lacinia. Aenean vehicula ante nunc, at dignissim dolor porta eu. In hac
|
||||
habitasse platea dictumst. In hac habitasse platea dictumst. Cras ac ex
|
||||
molestie, pulvinar nisi a, finibus est.
|
||||
|
||||
Integer a eros ut tortor convallis porta ultrices id justo. Maecenas luctus
|
||||
purus id molestie molestie. Vestibulum rutrum consequat porta. Fusce
|
||||
vulputate lacus sed nisl venenatis rhoncus. Aliquam erat volutpat.
|
||||
Suspendisse viverra eros id erat congue, nec convallis arcu volutpat. Etiam
|
||||
non sem nisi.
|
||||
|
||||
Integer at velit sed mauris luctus sagittis. Suspendisse aliquam diam non
|
||||
enim viverra, suscipit fermentum massa accumsan. Cras eget ex justo. Aenean
|
||||
non scelerisque elit. Duis et nulla quis est dignissim bibendum. Quisque
|
||||
mattis dui vitae convallis pellentesque. Mauris arcu dui, aliquet non
|
||||
ligula et, posuere tincidunt felis.
|
||||
|
||||
In id tortor sollicitudin, convallis elit ut, auctor libero. Nunc ut lorem
|
||||
a quam tempor lobortis. Praesent nec dolor ut erat fermentum malesuada. Cum
|
||||
sociis natoque penatibus et magnis dis parturient montes, nascetur
|
||||
ridiculus mus. Nulla facilisi. Vestibulum eget ornare justo. Donec iaculis
|
||||
purus eget massa mattis bibendum. Quisque commodo efficitur magna, ac
|
||||
tempor ipsum ultrices eu. Suspendisse id felis molestie, consequat neque
|
||||
in, vehicula velit. In a dictum dui, non tempor elit. Phasellus luctus nec
|
||||
eros viverra consequat. Aliquam efficitur metus consectetur, rhoncus sem
|
||||
vitae, facilisis arcu. Nullam eget nunc in urna mollis laoreet a at eros.
|
||||
|
||||
--001a113d2d045cd63c051e1383c0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<div dir=3D"ltr"><div>Lorem ipsum dolor sit amet, consectetur adipiscing el=
|
||||
it. Integer at ex urna. Nunc sollicitudin venenatis dolor, eget convallis l=
|
||||
ibero convallis eu. Nulla luctus ligula in magna condimentum, placerat laci=
|
||||
nia nulla varius. Curabitur quis placerat enim, et aliquam ligula. In digni=
|
||||
ssim lectus vel pharetra posuere. Proin imperdiet augue orci, at dapibus ur=
|
||||
na sagittis vel. In nec arcu placerat, vehicula est vel, sollicitudin erat.=
|
||||
Nunc tempor risus lorem, sed bibendum tellus pretium non. Sed vel tortor a=
|
||||
nte. Donec convallis erat ac mauris mollis feugiat. Donec pharetra ex ac te=
|
||||
mpus aliquam. Praesent ut purus tristique, pharetra arcu vulputate, eleifen=
|
||||
d felis.</div><div><br></div><div>Aenean ut porttitor risus, a porta nunc. =
|
||||
Donec ligula diam, sagittis at luctus id, luctus sit amet risus. Sed turpis=
|
||||
nisl, fermentum vitae nibh non, imperdiet luctus arcu. Fusce vulputate vel=
|
||||
it porta, rutrum velit nec, semper eros. Suspendisse in dui non nulla lacin=
|
||||
ia tincidunt. Vivamus sit amet lectus eu velit condimentum gravida. Curabit=
|
||||
ur vestibulum felis nisl, a sagittis odio faucibus a. Nulla fermentum, ligu=
|
||||
la in gravida hendrerit, diam dui tempor dolor, id euismod tortor velit at =
|
||||
massa. Phasellus et nunc mi. Integer tristique viverra odio vitae auctor. L=
|
||||
orem ipsum dolor sit amet, consectetur adipiscing elit. Nunc rutrum turpis =
|
||||
ornare lorem ornare lacinia. Aenean vehicula ante nunc, at dignissim dolor =
|
||||
porta eu. In hac habitasse platea dictumst. In hac habitasse platea dictums=
|
||||
t. Cras ac ex molestie, pulvinar nisi a, finibus est.</div><div><br></div><=
|
||||
div>Integer a eros ut tortor convallis porta ultrices id justo. Maecenas lu=
|
||||
ctus purus id molestie molestie. Vestibulum rutrum consequat porta. Fusce v=
|
||||
ulputate lacus sed nisl venenatis rhoncus. Aliquam erat volutpat. Suspendis=
|
||||
se viverra eros id erat congue, nec convallis arcu volutpat. Etiam non sem =
|
||||
nisi.</div><div><br></div><div>Integer at velit sed mauris luctus sagittis.=
|
||||
Suspendisse aliquam diam non enim viverra, suscipit fermentum massa accums=
|
||||
an. Cras eget ex justo. Aenean non scelerisque elit. Duis et nulla quis est=
|
||||
dignissim bibendum. Quisque mattis dui vitae convallis pellentesque. Mauri=
|
||||
s arcu dui, aliquet non ligula et, posuere tincidunt felis.</div><div><br><=
|
||||
/div><div>In id tortor sollicitudin, convallis elit ut, auctor libero. Nunc=
|
||||
ut lorem a quam tempor lobortis. Praesent nec dolor ut erat fermentum male=
|
||||
suada. Cum sociis natoque penatibus et magnis dis parturient montes, nascet=
|
||||
ur ridiculus mus. Nulla facilisi. Vestibulum eget ornare justo. Donec iacul=
|
||||
is purus eget massa mattis bibendum. Quisque commodo efficitur magna, ac te=
|
||||
mpor ipsum ultrices eu. Suspendisse id felis molestie, consequat neque in, =
|
||||
vehicula velit. In a dictum dui, non tempor elit. Phasellus luctus nec eros=
|
||||
viverra consequat. Aliquam efficitur metus consectetur, rhoncus sem vitae,=
|
||||
facilisis arcu. Nullam eget nunc in urna mollis laoreet a at eros.</div>
|
||||
</div>
|
||||
|
||||
--001a113d2d045cd63c051e1383c0--
|
||||
--001a113d2d045cd646051e1383c2
|
||||
Content-Type: text/plain; charset=US-ASCII; name="README.txt"
|
||||
Content-Disposition: attachment; filename="README.txt"
|
||||
Content-Transfer-Encoding: base64
|
||||
X-Attachment-Id: f_idqb67l61
|
||||
|
||||
SW5idWNrZXQgWyFbQnVpbGQgU3RhdHVzXShodHRwczovL3RyYXZpcy1jaS5vcmcvamhpbGx5ZXJk
|
||||
L2luYnVja2V0LnBuZz9icmFuY2g9bWFzdGVyKV0oaHR0cHM6Ly90cmF2aXMtY2kub3JnL2poaWxs
|
||||
eWVyZC9pbmJ1Y2tldCkKPT09PT09PT0KCkluYnVja2V0IGlzIGFuIGVtYWlsIHRlc3Rpbmcgc2Vy
|
||||
dmljZTsgaXQgd2lsbCBhY2NlcHQgbWVzc2FnZXMgZm9yIGFueSBlbWFpbAphZGRyZXNzIGFuZCBt
|
||||
YWtlIHRoZW0gYXZhaWxhYmxlIHZpYSB3ZWIsIFJFU1QgYW5kIFBPUDMuICBPbmNlIGNvbXBpbGVk
|
||||
LApJbmJ1Y2tldCBkb2VzIG5vdCBoYXZlIGFuIGV4dGVybmFsIGRlcGVuZGVuY2llcyAoSFRUUCwg
|
||||
U01UUCwgUE9QMyBhbmQgc3RvcmFnZQphcmUgYWxsIGJ1aWx0IGluKS4KClJlYWQgbW9yZSBhdCB0
|
||||
aGUgW0luYnVja2V0IHdlYnNpdGVdW0luYnVja2V0XQoKRGV2ZWxvcG1lbnQgU3RhdHVzCi0tLS0t
|
||||
LS0tLS0tLS0tLS0tLQoKSW5idWNrZXQgaXMgY3VycmVudGx5IHByb2R1Y3Rpb24gcXVhbGl0eTog
|
||||
aXQgaXMgYmVpbmcgdXNlZCBmb3IgcmVhbCB3b3JrLgoKUGxlYXNlIGNoZWNrIHRoZSBbaXNzdWVz
|
||||
IGxpc3RdW0lzc3Vlc10KZm9yIG1vcmUgZGV0YWlscy4KCkJ1aWxkaW5nIGZyb20gU291cmNlCi0t
|
||||
LS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKWW91IHdpbGwgbmVlZCBhIGZ1bmN0aW9uaW5nIFtHbyBp
|
||||
bnN0YWxsYXRpb25dW0dvbGFuZ10gZm9yIHRoaXMgdG8gd29yay4KCkdyYWIgdGhlIEluYnVja2V0
|
||||
IHNvdXJjZSBjb2RlIGFuZCBjb21waWxlIHRoZSBkYWVtb246CgogICAgZ28gZ2V0IC12IGdpdGh1
|
||||
Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0CgpFZGl0IGV0Yy9pbmJ1Y2tldC5jb25mIGFuZCB0YWls
|
||||
b3IgdG8geW91ciBlbnZpcm9ubWVudC4gIEl0IHNob3VsZCB3b3JrIG9uIG1vc3QKVW5peCBhbmQg
|
||||
T1MgWCBtYWNoaW5lcyBhcyBpcy4gIExhdW5jaCB0aGUgZGFlbW9uOgoKICAgICRHT1BBVEgvYmlu
|
||||
L2luYnVja2V0ICRHT1BBVEgvc3JjL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0L2V0Yy9p
|
||||
bmJ1Y2tldC5jb25mCgpCeSBkZWZhdWx0IHRoZSBTTVRQIHNlcnZlciB3aWxsIGJlIGxpc3Rlbmlu
|
||||
ZyBvbiBsb2NhbGhvc3QgcG9ydCAyNTAwIGFuZAp0aGUgd2ViIGludGVyZmFjZSB3aWxsIGJlIGF2
|
||||
YWlsYWJsZSBhdCBbbG9jYWxob3N0OjkwMDBdKGh0dHA6Ly9sb2NhbGhvc3Q6OTAwMC8pLgoKVGhl
|
||||
IEluYnVja2V0IHdlYnNpdGUgaGFzIGEgbW9yZSBjb21wbGV0ZSBndWlkZSB0bwpbaW5zdGFsbGlu
|
||||
ZyBmcm9tIHNvdXJjZV1bRnJvbSBTb3VyY2VdCgpBYm91dAotLS0tLQoKSW5idWNrZXQgaXMgd3Jp
|
||||
dHRlbiBpbiBbR29vZ2xlIEdvXVtHb2xhbmddLgoKSW5idWNrZXQgaXMgb3BlbiBzb3VyY2Ugc29m
|
||||
dHdhcmUgcmVsZWFzZWQgdW5kZXIgdGhlIE1JVCBMaWNlbnNlLiAgVGhlIGxhdGVzdAp2ZXJzaW9u
|
||||
IGNhbiBiZSBmb3VuZCBhdCBodHRwczovL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0Cgpb
|
||||
SW5idWNrZXRdOiBodHRwOi8vd3d3LmluYnVja2V0Lm9yZy8KW0lzc3Vlc106IGh0dHBzOi8vZ2l0
|
||||
aHViLmNvbS9qaGlsbHllcmQvaW5idWNrZXQvaXNzdWVzP3N0YXRlPW9wZW4KW0Zyb20gU291cmNl
|
||||
XTogaHR0cDovL3d3dy5pbmJ1Y2tldC5vcmcvaW5zdGFsbGF0aW9uL2Zyb20tc291cmNlLmh0bWwK
|
||||
W0dvbGFuZ106IGh0dHA6Ly9nb2xhbmcub3JnLwo=
|
||||
--001a113d2d045cd646051e1383c2
|
||||
Content-Type: image/png; name="favicon.png"
|
||||
Content-Disposition: attachment; filename="favicon.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
X-Attachment-Id: f_idqb60e30
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAK
|
||||
T2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AU
|
||||
kSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXX
|
||||
Pues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgAB
|
||||
eNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAt
|
||||
AGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3
|
||||
AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dX
|
||||
Lh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+
|
||||
5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk
|
||||
5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd
|
||||
0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA
|
||||
4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzA
|
||||
BhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/ph
|
||||
CJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5
|
||||
h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+
|
||||
Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhM
|
||||
WE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQ
|
||||
AkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+Io
|
||||
UspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdp
|
||||
r+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZ
|
||||
D5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61Mb
|
||||
U2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY
|
||||
/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllir
|
||||
SKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79u
|
||||
p+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6Vh
|
||||
lWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1
|
||||
mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lO
|
||||
k06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7Ry
|
||||
FDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3I
|
||||
veRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+B
|
||||
Z7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/
|
||||
0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5p
|
||||
DoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5q
|
||||
PNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIs
|
||||
OpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5
|
||||
hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQ
|
||||
rAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9
|
||||
rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1d
|
||||
T1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aX
|
||||
Dm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7
|
||||
vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3S
|
||||
PVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKa
|
||||
RptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO
|
||||
32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21
|
||||
e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfV
|
||||
P1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i
|
||||
/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8
|
||||
IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADq
|
||||
YAAAOpgAABdvkl/FRgAAAPxJREFUeNqkU0ERgzAQ3DA1EAuxgAUsYAELsYCFWKASqASQUCRQCdfP
|
||||
HuxkYProzRxJLsmyd3sJZoZ/7AEAIYQ63gLo6G4vAE8AmwfMjJ/TIoAJgNFn+iKxfAcQeXDnoVix
|
||||
SgKerwAKN1v6LCwGGSf+JCpAEuTIA355F/o9wQ3AoACZwSRzz3UQgOTpA8gKMAF4c9Pr4NYJgNfq
|
||||
AGgkuImEayWpywimcaybm/5IAjxwvnKdeXk9tQRGoehSLUxLe8JjsZaxq1RwCUcB3yl1vGskl6z9
|
||||
0f69M6oBklAu1TtwlkXYpLu3UCTvKy9ag2BmV68xSku7baz+R2vwHQC+QKj9KkHDLAAAAABJRU5E
|
||||
rkJggg==
|
||||
--001a113d2d045cd646051e1383c2--
|
||||
393
swaks-tests/nonmime-html-inlined.raw
Normal file
393
swaks-tests/nonmime-html-inlined.raw
Normal file
@@ -0,0 +1,393 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: tutsplus responsive inlined CSS
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<style type="text/css">
|
||||
table {border-collapse: collapse !important;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style="margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;background-color:#ffffff;" >
|
||||
<center class="wrapper" style="width:100%;table-layout:fixed;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;" >
|
||||
<div class="webkit" style="max-width:600px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;" >
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="600" align="center" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<table class="outer" align="center" style="border-spacing:0;font-family:sans-serif;color:#333333;Margin:0 auto;width:100%;max-width:600px;" >
|
||||
<tr>
|
||||
<td class="full-width-image" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/header.jpg" width="600" alt="" style="border-width:0;width:100%;max-width:600px;height:auto;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="one-column" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;text-align:left;" >
|
||||
<p class="h1" style="Margin:0;font-weight:bold;font-size:14px;Margin-bottom:10px;" >Lorem ipsum dolor sit amet</p>
|
||||
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >
|
||||
Compare to:
|
||||
<a href="http://tutsplus.github.io/creating-a-future-proof-responsive-email-without-media-queries/index.html" style="color:#ee6a56;text-decoration:underline;" >
|
||||
tutsplus sample</a>
|
||||
</p>
|
||||
|
||||
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >Copyright (c) 2015, Envato Tuts+<br/>
|
||||
All rights reserved.</p>
|
||||
|
||||
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:</p>
|
||||
|
||||
<ul>
|
||||
<li>Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.</li>
|
||||
|
||||
<li>Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.</li>
|
||||
</ul>
|
||||
|
||||
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="two-column" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td width="50%" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:300px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:left;" >
|
||||
<tr>
|
||||
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/two-column-01.jpg" width="280" alt="" style="border-width:0;width:100%;max-width:280px;height:auto;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="50%" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:300px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:left;" >
|
||||
<tr>
|
||||
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/two-column-02.jpg" width="280" alt="" style="border-width:0;width:100%;max-width:280px;height:auto;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="three-column" style="padding-right:0;padding-left:0;text-align:center;font-size:0;padding-top:10px;padding-bottom:10px;" >
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
|
||||
<tr>
|
||||
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/three-column-01.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||
Scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
|
||||
<tr>
|
||||
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/three-column-02.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
|
||||
<tr>
|
||||
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/three-column-03.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
|
||||
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="three-column" style="padding-right:0;padding-left:0;text-align:center;font-size:0;padding-top:10px;padding-bottom:10px;" >
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Fashion</p>
|
||||
<p style="Margin:0;" >Class eleifend aptent taciti sociosqu ad litora torquent conubia</p>
|
||||
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Read requirements</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Photography</p>
|
||||
<p style="Margin:0;" >Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See examples</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Design</p>
|
||||
<p style="Margin:0;" >Class aptent taciti sociosqu eleifend ad litora per conubia nostra</p>
|
||||
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See the winners</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Cooking</p>
|
||||
<p style="Margin:0;" >Class aptent taciti eleifend sociosqu ad litora torquent conubia</p>
|
||||
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Read recipes</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Woodworking</p>
|
||||
<p style="Margin:0;" >Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See examples</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Craft</p>
|
||||
<p style="Margin:0;" >Class aptent taciti sociosqu ad eleifend litora per conubia nostra</p>
|
||||
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Vote now</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left-sidebar" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td width="100" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column left" style="width:100%;display:inline-block;vertical-align:middle;max-width:100px;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-01.jpg" width="80" alt="" style="border-width:0;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="500" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column right" style="width:100%;display:inline-block;vertical-align:middle;max-width:500px;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat. <a href="#" style="text-decoration:underline;color:#85ab70;" >Read on</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="right-sidebar" dir="rtl" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%" dir="rtl" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td width="100" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column left" dir="ltr" style="width:100%;display:inline-block;vertical-align:middle;max-width:100px;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-02.jpg" width="80" alt="" style="border-width:0;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="500" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
|
||||
<![endif]-->
|
||||
<div class="column right" dir="ltr" style="width:100%;display:inline-block;vertical-align:middle;max-width:500px;" >
|
||||
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
|
||||
<tr>
|
||||
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
|
||||
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra. <a href="#" style="text-decoration:underline;color:#70bbd9;" >Per inceptos</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</div>
|
||||
</center>
|
||||
</body>
|
||||
</html>
|
||||
394
swaks-tests/nonmime-html-responsive.raw
Normal file
394
swaks-tests/nonmime-html-responsive.raw
Normal file
@@ -0,0 +1,394 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: tutsplus responsive external CSS
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<link rel="stylesheet" type="text/css" href="http://www.inbucket.org/email-assets/responsive/styles.css" />
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<style type="text/css">
|
||||
table {border-collapse: collapse;}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<center class="wrapper">
|
||||
<div class="webkit">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="600" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table class="outer" align="center">
|
||||
<tr>
|
||||
<td class="full-width-image">
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/header.jpg" width="600" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="one-column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h1">Lorem ipsum dolor sit amet</p>
|
||||
<p>
|
||||
Compare to:
|
||||
<a href="http://tutsplus.github.io/creating-a-future-proof-responsive-email-without-media-queries/index.html">
|
||||
tutsplus sample</a>
|
||||
</p>
|
||||
|
||||
<p>Copyright (c) 2015, Envato Tuts+<br/>
|
||||
All rights reserved.</p>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:</p>
|
||||
|
||||
<ul>
|
||||
<li>Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.</li>
|
||||
|
||||
<li>Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.</li>
|
||||
</ul>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="two-column">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner">
|
||||
<table class="contents">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/two-column-01.jpg" width="280" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text">
|
||||
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="50%" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner">
|
||||
<table class="contents">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/two-column-02.jpg" width="280" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text">
|
||||
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="three-column">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner">
|
||||
<table class="contents">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/three-column-01.jpg" width="180" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text">
|
||||
Scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner">
|
||||
<table class="contents">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/three-column-02.jpg" width="180" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text">
|
||||
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner">
|
||||
<table class="contents">
|
||||
<tr>
|
||||
<td>
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/three-column-03.jpg" width="180" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text">
|
||||
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="three-column">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h2">Fashion</p>
|
||||
<p>Class eleifend aptent taciti sociosqu ad litora torquent conubia</p>
|
||||
<p><a href="#">Read requirements</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h2">Photography</p>
|
||||
<p>Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||
<p><a href="#">See examples</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h2">Design</p>
|
||||
<p>Class aptent taciti sociosqu eleifend ad litora per conubia nostra</p>
|
||||
<p><a href="#">See the winners</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h2">Cooking</p>
|
||||
<p>Class aptent taciti eleifend sociosqu ad litora torquent conubia</p>
|
||||
<p><a href="#">Read recipes</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h2">Woodworking</p>
|
||||
<p>Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
|
||||
<p><a href="#">See examples</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="200" valign="top">
|
||||
<![endif]-->
|
||||
<div class="column">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<p class="h2">Craft</p>
|
||||
<p>Class aptent taciti sociosqu ad eleifend litora per conubia nostra</p>
|
||||
<p><a href="#">Vote now</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left-sidebar">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="100">
|
||||
<![endif]-->
|
||||
<div class="column left">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner">
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-01.jpg" width="80" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="500">
|
||||
<![endif]-->
|
||||
<div class="column right">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat. <a href="#">Read on</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="right-sidebar" dir="rtl">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="100%" dir="rtl">
|
||||
<tr>
|
||||
<td width="100">
|
||||
<![endif]-->
|
||||
<div class="column left" dir="ltr">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-02.jpg" width="80" alt="" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td><td width="500">
|
||||
<![endif]-->
|
||||
<div class="column right" dir="ltr">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td class="inner contents">
|
||||
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra. <a href="#">Per inceptos</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</div>
|
||||
</center>
|
||||
</body>
|
||||
</html>
|
||||
10
swaks-tests/nonmime-html.raw
Normal file
10
swaks-tests/nonmime-html.raw
Normal file
@@ -0,0 +1,10 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: Swaks HTML
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<p style="font-family: 'Courier New', Courier, monospace;">
|
||||
This is a test of <b>HTML</b> at the <i>top</i> level.
|
||||
</p>
|
||||
322
swaks-tests/outlook.raw
Normal file
322
swaks-tests/outlook.raw
Normal file
@@ -0,0 +1,322 @@
|
||||
From: %FROM_ADDRESS%
|
||||
To: %TO_ADDRESS%
|
||||
Subject: Test from Outlook
|
||||
Thread-Topic: Test from Outlook
|
||||
Thread-Index: AdDeqI993CvUm800TamFq90sKu975w==
|
||||
Date: %DATE%
|
||||
Message-ID: <8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADD@exch.com>
|
||||
Accept-Language: en-US
|
||||
Content-Language: en-US
|
||||
X-MS-Has-Attach: yes
|
||||
X-MS-TNEF-Correlator:
|
||||
x-originating-ip: [10.13.30.10]
|
||||
Content-Type: multipart/mixed;
|
||||
boundary="_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_"
|
||||
MIME-Version: 1.0
|
||||
|
||||
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_"
|
||||
|
||||
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||
Content-Type: text/plain; charset="us-ascii"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque liber=
|
||||
o arcu, accumsan at mattis nec, condimentum vitae erat. Praesent massa turp=
|
||||
is, iaculis elementum lectus vitae, iaculis laoreet massa. Integer porta, n=
|
||||
isi eget congue vulputate, tellus lacus imperdiet tellus, in tristique metu=
|
||||
s nibh sed est. Vestibulum ullamcorper arcu sed lacus viverra tristique. Ve=
|
||||
stibulum mattis id ante eget aliquam. Nam pulvinar, libero dignissim posuer=
|
||||
e tincidunt, ipsum est finibus enim, id accumsan augue lectus eu massa. In =
|
||||
dapibus consequat velit quis interdum. Cras vel augue pellentesque tortor i=
|
||||
nterdum molestie. Fusce ut dui semper, ultricies nulla a, tempor lacus. In =
|
||||
interdum velit in justo dapibus iaculis. Quisque et neque turpis. Aenean id=
|
||||
nunc sodales, ultrices turpis non, molestie nibh.
|
||||
|
||||
In euismod aliquam tortor ac ornare. Donec nisi ante, lacinia eget placerat=
|
||||
at, tincidunt id ante. Vestibulum mauris nisi, consectetur vitae dolor qui=
|
||||
s, maximus auctor tellus. In dignissim mi blandit, laoreet mi vitae, gravid=
|
||||
a risus. Sed rhoncus nisi velit, at condimentum velit efficitur id. Duis ia=
|
||||
culis dictum tempor. Ut consectetur nisi in ex viverra interdum. Cras eget =
|
||||
vestibulum libero. Vestibulum in efficitur ante, id tristique elit. Aliquam=
|
||||
justo dolor, sagittis et dui vitae, gravida pellentesque lectus. Sed venen=
|
||||
atis imperdiet cursus. Nulla quis nulla eu nisi tempor varius et ut elit. I=
|
||||
nterdum et malesuada fames ac ante ipsum primis in faucibus. Mauris placera=
|
||||
t interdum eros, vitae molestie urna consequat et.
|
||||
|
||||
Cras massa dolor, congue eu magna et, sagittis eleifend quam. Donec volutpa=
|
||||
t congue leo, in sodales purus mollis nec. Integer vehicula, odio eget cong=
|
||||
ue interdum, erat nunc interdum nisi, quis congue nunc felis eu ipsum. Sed =
|
||||
bibendum massa dui, et sodales lacus dignissim ut. Nulla et orci vitae lacu=
|
||||
s dignissim elementum eu sollicitudin turpis. Phasellus leo lorem, pellente=
|
||||
sque sit amet ultricies varius, bibendum sed ligula. Nam eros orci, facilis=
|
||||
is vel lacus vitae, suscipit tincidunt nibh. In id magna a velit molestie a=
|
||||
uctor. Etiam a nunc ligula. Sed hendrerit, felis quis pharetra bibendum, to=
|
||||
rtor sem tempor lacus, et hendrerit quam nisl ac metus. Ut convallis congue=
|
||||
lectus, eu scelerisque nisl pharetra sed. Vestibulum ante ipsum primis in =
|
||||
faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam ut turpis n=
|
||||
isi. Phasellus ac dolor laoreet, commodo turpis quis, ultricies turpis. Qui=
|
||||
sque mollis lorem vestibulum diam sollicitudin, in tempor mi accumsan. Proi=
|
||||
n metus nunc, fringilla eu sagittis nec, rhoncus semper sem.
|
||||
|
||||
Fusce posuere et felis ut ornare. Proin sodales sollicitudin tellus, non tr=
|
||||
istique ex egestas at. Pellentesque arcu sem, vulputate a diam eget, blandi=
|
||||
t lobortis libero. Donec accumsan, diam vel congue hendrerit, mi mi vestibu=
|
||||
lum ex, ut elementum est magna eu augue. Aliquam consequat arcu eu velit tr=
|
||||
istique placerat. Quisque facilisis tempor ipsum, sit amet iaculis nunc mal=
|
||||
esuada a. Morbi laoreet fringilla odio sed volutpat. Integer scelerisque in=
|
||||
terdum massa et fringilla. Nam pulvinar iaculis nibh, id condimentum nunc. =
|
||||
Integer eleifend, dui in iaculis sollicitudin, lacus dui feugiat felis, ut =
|
||||
efficitur urna sem at neque. Donec at sapien malesuada orci imperdiet eleme=
|
||||
ntum. Nulla luctus tristique enim, eu euismod diam ultricies vel. Ut dui pu=
|
||||
rus, commodo eu tristique nec, accumsan vel lorem. Phasellus sagittis ullam=
|
||||
corper vulputate. Vestibulum sed nulla tristique, tristique lectus quis, pu=
|
||||
lvinar quam.
|
||||
|
||||
Duis sollicitudin convallis lacinia. Maecenas erat eros, laoreet ut sapien =
|
||||
vitae, convallis semper neque. Ut est turpis, pharetra sed tortor id, luctu=
|
||||
s pretium odio. Mauris laoreet dapibus iaculis. Pellentesque augue tortor, =
|
||||
lacinia eget mauris eu, hendrerit ultricies turpis. Vestibulum suscipit nis=
|
||||
i ligula, at tempus augue vulputate ac. Vestibulum leo mauris, tristique se=
|
||||
d cursus a, sodales a erat. Sed accumsan justo at dui ornare, at placerat a=
|
||||
ugue vestibulum.
|
||||
|
||||
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||
Content-Type: text/html; charset="us-ascii"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-micr=
|
||||
osoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:office:word" =
|
||||
xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xmlns=3D"http:=
|
||||
//www.w3.org/TR/REC-html40">
|
||||
<head>
|
||||
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dus-ascii"=
|
||||
>
|
||||
<meta name=3D"Generator" content=3D"Microsoft Word 15 (filtered medium)">
|
||||
<style><!--
|
||||
/* Font Definitions */
|
||||
@font-face
|
||||
{font-family:"MS Mincho";
|
||||
panose-1:2 2 6 9 4 2 5 8 3 4;}
|
||||
@font-face
|
||||
{font-family:"Cambria Math";
|
||||
panose-1:2 4 5 3 5 4 6 3 2 4;}
|
||||
@font-face
|
||||
{font-family:Calibri;
|
||||
panose-1:2 15 5 2 2 2 4 3 2 4;}
|
||||
@font-face
|
||||
{font-family:"\@MS Mincho";
|
||||
panose-1:2 2 6 9 4 2 5 8 3 4;}
|
||||
/* Style Definitions */
|
||||
p.MsoNormal, li.MsoNormal, div.MsoNormal
|
||||
{margin:0in;
|
||||
margin-bottom:.0001pt;
|
||||
font-size:11.0pt;
|
||||
font-family:"Calibri",sans-serif;}
|
||||
a:link, span.MsoHyperlink
|
||||
{mso-style-priority:99;
|
||||
color:#0563C1;
|
||||
text-decoration:underline;}
|
||||
a:visited, span.MsoHyperlinkFollowed
|
||||
{mso-style-priority:99;
|
||||
color:#954F72;
|
||||
text-decoration:underline;}
|
||||
span.EmailStyle17
|
||||
{mso-style-type:personal-compose;
|
||||
font-family:"Calibri",sans-serif;
|
||||
color:windowtext;}
|
||||
.MsoChpDefault
|
||||
{mso-style-type:export-only;}
|
||||
@page WordSection1
|
||||
{size:8.5in 11.0in;
|
||||
margin:1.0in 1.0in 1.0in 1.0in;}
|
||||
div.WordSection1
|
||||
{page:WordSection1;}
|
||||
--></style><!--[if gte mso 9]><xml>
|
||||
<o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
|
||||
</xml><![endif]--><!--[if gte mso 9]><xml>
|
||||
<o:shapelayout v:ext=3D"edit">
|
||||
<o:idmap v:ext=3D"edit" data=3D"1" />
|
||||
</o:shapelayout></xml><![endif]-->
|
||||
</head>
|
||||
<body lang=3D"EN-US" link=3D"#0563C1" vlink=3D"#954F72">
|
||||
<div class=3D"WordSection1">
|
||||
<p class=3D"MsoNormal">Lorem ipsum dolor sit amet, consectetur adipiscing e=
|
||||
lit. Pellentesque libero arcu, accumsan at mattis nec, condimentum vitae er=
|
||||
at. Praesent massa turpis, iaculis elementum lectus vitae, iaculis laoreet =
|
||||
massa. Integer porta, nisi eget congue
|
||||
vulputate, tellus lacus imperdiet tellus, in tristique metus nibh sed est.=
|
||||
Vestibulum ullamcorper arcu sed lacus viverra tristique. Vestibulum mattis=
|
||||
id ante eget aliquam. Nam pulvinar, libero dignissim posuere tincidunt, ip=
|
||||
sum est finibus enim, id accumsan
|
||||
augue lectus eu massa. In dapibus consequat velit quis interdum. Cras vel =
|
||||
augue pellentesque tortor interdum molestie. Fusce ut dui semper, ultricies=
|
||||
nulla a, tempor lacus. In interdum velit in justo dapibus iaculis. Quisque=
|
||||
et neque turpis. Aenean id nunc
|
||||
sodales, ultrices turpis non, molestie nibh.<o:p></o:p></p>
|
||||
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||
<p class=3D"MsoNormal">In euismod aliquam tortor ac ornare. Donec nisi ante=
|
||||
, lacinia eget placerat at, tincidunt id ante. Vestibulum mauris nisi, cons=
|
||||
ectetur vitae dolor quis, maximus auctor tellus. In dignissim mi blandit, l=
|
||||
aoreet mi vitae, gravida risus. Sed
|
||||
rhoncus nisi velit, at condimentum velit efficitur id. Duis iaculis dictum=
|
||||
tempor. Ut consectetur nisi in ex viverra interdum. Cras eget vestibulum l=
|
||||
ibero. Vestibulum in efficitur ante, id tristique elit. Aliquam justo dolor=
|
||||
, sagittis et dui vitae, gravida
|
||||
pellentesque lectus. Sed venenatis imperdiet cursus. Nulla quis nulla eu n=
|
||||
isi tempor varius et ut elit. Interdum et malesuada fames ac ante ipsum pri=
|
||||
mis in faucibus. Mauris placerat interdum eros, vitae molestie urna consequ=
|
||||
at et.<o:p></o:p></p>
|
||||
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||
<p class=3D"MsoNormal">Cras massa dolor, congue eu magna et, sagittis eleif=
|
||||
end quam. Donec volutpat congue leo, in sodales purus mollis nec. Integer v=
|
||||
ehicula, odio eget congue interdum, erat nunc interdum nisi, quis congue nu=
|
||||
nc felis eu ipsum. Sed bibendum massa
|
||||
dui, et sodales lacus dignissim ut. Nulla et orci vitae lacus dignissim el=
|
||||
ementum eu sollicitudin turpis. Phasellus leo lorem, pellentesque sit amet =
|
||||
ultricies varius, bibendum sed ligula. Nam eros orci, facilisis vel lacus v=
|
||||
itae, suscipit tincidunt nibh. In
|
||||
id magna a velit molestie auctor. Etiam a nunc ligula. Sed hendrerit, feli=
|
||||
s quis pharetra bibendum, tortor sem tempor lacus, et hendrerit quam nisl a=
|
||||
c metus. Ut convallis congue lectus, eu scelerisque nisl pharetra sed. Vest=
|
||||
ibulum ante ipsum primis in faucibus
|
||||
orci luctus et ultrices posuere cubilia Curae; Aliquam ut turpis nisi. Pha=
|
||||
sellus ac dolor laoreet, commodo turpis quis, ultricies turpis. Quisque mol=
|
||||
lis lorem vestibulum diam sollicitudin, in tempor mi accumsan. Proin metus =
|
||||
nunc, fringilla eu sagittis nec,
|
||||
rhoncus semper sem.<o:p></o:p></p>
|
||||
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||
<p class=3D"MsoNormal">Fusce posuere et felis ut ornare. Proin sodales soll=
|
||||
icitudin tellus, non tristique ex egestas at. Pellentesque arcu sem, vulput=
|
||||
ate a diam eget, blandit lobortis libero. Donec accumsan, diam vel congue h=
|
||||
endrerit, mi mi vestibulum ex, ut
|
||||
elementum est magna eu augue. Aliquam consequat arcu eu velit tristique pl=
|
||||
acerat. Quisque facilisis tempor ipsum, sit amet iaculis nunc malesuada a. =
|
||||
Morbi laoreet fringilla odio sed volutpat. Integer scelerisque interdum mas=
|
||||
sa et fringilla. Nam pulvinar iaculis
|
||||
nibh, id condimentum nunc. Integer eleifend, dui in iaculis sollicitudin, =
|
||||
lacus dui feugiat felis, ut efficitur urna sem at neque. Donec at sapien ma=
|
||||
lesuada orci imperdiet elementum. Nulla luctus tristique enim, eu euismod d=
|
||||
iam ultricies vel. Ut dui purus,
|
||||
commodo eu tristique nec, accumsan vel lorem. Phasellus sagittis ullamcorp=
|
||||
er vulputate. Vestibulum sed nulla tristique, tristique lectus quis, pulvin=
|
||||
ar quam.<o:p></o:p></p>
|
||||
<p class=3D"MsoNormal"><o:p> </o:p></p>
|
||||
<p class=3D"MsoNormal">Duis sollicitudin convallis lacinia. Maecenas erat e=
|
||||
ros, laoreet ut sapien vitae, convallis semper neque. Ut est turpis, pharet=
|
||||
ra sed tortor id, luctus pretium odio. Mauris laoreet dapibus iaculis. Pell=
|
||||
entesque augue tortor, lacinia eget
|
||||
mauris eu, hendrerit ultricies turpis. Vestibulum suscipit nisi ligula, at=
|
||||
tempus augue vulputate ac. Vestibulum leo mauris, tristique sed cursus a, =
|
||||
sodales a erat. Sed accumsan justo at dui ornare, at placerat augue vestibu=
|
||||
lum.<o:p></o:p></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_--
|
||||
|
||||
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||
Content-Type: image/png; name="favicon.png"
|
||||
Content-Description: favicon.png
|
||||
Content-Disposition: attachment; filename="favicon.png"; size=3025;
|
||||
creation-date="Mon, 24 Aug 2015 19:05:03 GMT";
|
||||
modification-date="Mon, 24 Aug 2015 19:05:03 GMT"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAK
|
||||
T2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AU
|
||||
kSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXX
|
||||
Pues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgAB
|
||||
eNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAt
|
||||
AGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3
|
||||
AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dX
|
||||
Lh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+
|
||||
5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk
|
||||
5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd
|
||||
0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA
|
||||
4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzA
|
||||
BhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/ph
|
||||
CJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5
|
||||
h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+
|
||||
Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhM
|
||||
WE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQ
|
||||
AkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+Io
|
||||
UspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdp
|
||||
r+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZ
|
||||
D5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61Mb
|
||||
U2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY
|
||||
/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllir
|
||||
SKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79u
|
||||
p+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6Vh
|
||||
lWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1
|
||||
mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lO
|
||||
k06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7Ry
|
||||
FDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3I
|
||||
veRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+B
|
||||
Z7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/
|
||||
0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5p
|
||||
DoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5q
|
||||
PNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIs
|
||||
OpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5
|
||||
hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQ
|
||||
rAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9
|
||||
rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1d
|
||||
T1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aX
|
||||
Dm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7
|
||||
vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3S
|
||||
PVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKa
|
||||
RptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO
|
||||
32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21
|
||||
e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfV
|
||||
P1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i
|
||||
/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8
|
||||
IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADq
|
||||
YAAAOpgAABdvkl/FRgAAAPxJREFUeNqkU0ERgzAQ3DA1EAuxgAUsYAELsYCFWKASqASQUCRQCdfP
|
||||
HuxkYProzRxJLsmyd3sJZoZ/7AEAIYQ63gLo6G4vAE8AmwfMjJ/TIoAJgNFn+iKxfAcQeXDnoVix
|
||||
SgKerwAKN1v6LCwGGSf+JCpAEuTIA355F/o9wQ3AoACZwSRzz3UQgOTpA8gKMAF4c9Pr4NYJgNfq
|
||||
AGgkuImEayWpywimcaybm/5IAjxwvnKdeXk9tQRGoehSLUxLe8JjsZaxq1RwCUcB3yl1vGskl6z9
|
||||
0f69M6oBklAu1TtwlkXYpLu3UCTvKy9ag2BmV68xSku7baz+R2vwHQC+QKj9KkHDLAAAAABJRU5E
|
||||
rkJggg==
|
||||
|
||||
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
|
||||
Content-Type: text/plain; name="README.txt"
|
||||
Content-Description: README.txt
|
||||
Content-Disposition: attachment; filename="README.txt"; size=1682;
|
||||
creation-date="Mon, 24 Aug 2015 19:19:48 GMT";
|
||||
modification-date="Mon, 24 Aug 2015 19:19:48 GMT"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
SW5idWNrZXQgWyFbQnVpbGQgU3RhdHVzXShodHRwczovL3RyYXZpcy1jaS5vcmcvamhpbGx5ZXJk
|
||||
L2luYnVja2V0LnBuZz9icmFuY2g9bWFzdGVyKV0oaHR0cHM6Ly90cmF2aXMtY2kub3JnL2poaWxs
|
||||
eWVyZC9pbmJ1Y2tldCkKPT09PT09PT0KCkluYnVja2V0IGlzIGFuIGVtYWlsIHRlc3Rpbmcgc2Vy
|
||||
dmljZTsgaXQgd2lsbCBhY2NlcHQgbWVzc2FnZXMgZm9yIGFueSBlbWFpbAphZGRyZXNzIGFuZCBt
|
||||
YWtlIHRoZW0gYXZhaWxhYmxlIHZpYSB3ZWIsIFJFU1QgYW5kIFBPUDMuICBPbmNlIGNvbXBpbGVk
|
||||
LApJbmJ1Y2tldCBkb2VzIG5vdCBoYXZlIGFuIGV4dGVybmFsIGRlcGVuZGVuY2llcyAoSFRUUCwg
|
||||
U01UUCwgUE9QMyBhbmQgc3RvcmFnZQphcmUgYWxsIGJ1aWx0IGluKS4KClJlYWQgbW9yZSBhdCB0
|
||||
aGUgW0luYnVja2V0IHdlYnNpdGVdW0luYnVja2V0XQoKRGV2ZWxvcG1lbnQgU3RhdHVzCi0tLS0t
|
||||
LS0tLS0tLS0tLS0tLQoKSW5idWNrZXQgaXMgY3VycmVudGx5IHByb2R1Y3Rpb24gcXVhbGl0eTog
|
||||
aXQgaXMgYmVpbmcgdXNlZCBmb3IgcmVhbCB3b3JrLgoKUGxlYXNlIGNoZWNrIHRoZSBbaXNzdWVz
|
||||
IGxpc3RdW0lzc3Vlc10KZm9yIG1vcmUgZGV0YWlscy4KCkJ1aWxkaW5nIGZyb20gU291cmNlCi0t
|
||||
LS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKWW91IHdpbGwgbmVlZCBhIGZ1bmN0aW9uaW5nIFtHbyBp
|
||||
bnN0YWxsYXRpb25dW0dvbGFuZ10gZm9yIHRoaXMgdG8gd29yay4KCkdyYWIgdGhlIEluYnVja2V0
|
||||
IHNvdXJjZSBjb2RlIGFuZCBjb21waWxlIHRoZSBkYWVtb246CgogICAgZ28gZ2V0IC12IGdpdGh1
|
||||
Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0CgpFZGl0IGV0Yy9pbmJ1Y2tldC5jb25mIGFuZCB0YWls
|
||||
b3IgdG8geW91ciBlbnZpcm9ubWVudC4gIEl0IHNob3VsZCB3b3JrIG9uIG1vc3QKVW5peCBhbmQg
|
||||
T1MgWCBtYWNoaW5lcyBhcyBpcy4gIExhdW5jaCB0aGUgZGFlbW9uOgoKICAgICRHT1BBVEgvYmlu
|
||||
L2luYnVja2V0ICRHT1BBVEgvc3JjL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0L2V0Yy9p
|
||||
bmJ1Y2tldC5jb25mCgpCeSBkZWZhdWx0IHRoZSBTTVRQIHNlcnZlciB3aWxsIGJlIGxpc3Rlbmlu
|
||||
ZyBvbiBsb2NhbGhvc3QgcG9ydCAyNTAwIGFuZAp0aGUgd2ViIGludGVyZmFjZSB3aWxsIGJlIGF2
|
||||
YWlsYWJsZSBhdCBbbG9jYWxob3N0OjkwMDBdKGh0dHA6Ly9sb2NhbGhvc3Q6OTAwMC8pLgoKVGhl
|
||||
IEluYnVja2V0IHdlYnNpdGUgaGFzIGEgbW9yZSBjb21wbGV0ZSBndWlkZSB0bwpbaW5zdGFsbGlu
|
||||
ZyBmcm9tIHNvdXJjZV1bRnJvbSBTb3VyY2VdCgpBYm91dAotLS0tLQoKSW5idWNrZXQgaXMgd3Jp
|
||||
dHRlbiBpbiBbR29vZ2xlIEdvXVtHb2xhbmddLgoKSW5idWNrZXQgaXMgb3BlbiBzb3VyY2Ugc29m
|
||||
dHdhcmUgcmVsZWFzZWQgdW5kZXIgdGhlIE1JVCBMaWNlbnNlLiAgVGhlIGxhdGVzdAp2ZXJzaW9u
|
||||
IGNhbiBiZSBmb3VuZCBhdCBodHRwczovL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0Cgpb
|
||||
SW5idWNrZXRdOiBodHRwOi8vd3d3LmluYnVja2V0Lm9yZy8KW0lzc3Vlc106IGh0dHBzOi8vZ2l0
|
||||
aHViLmNvbS9qaGlsbHllcmQvaW5idWNrZXQvaXNzdWVzP3N0YXRlPW9wZW4KW0Zyb20gU291cmNl
|
||||
XTogaHR0cDovL3d3dy5pbmJ1Y2tldC5vcmcvaW5zdGFsbGF0aW9uL2Zyb20tc291cmNlLmh0bWwK
|
||||
W0dvbGFuZ106IGh0dHA6Ly9nb2xhbmcub3JnLwo=
|
||||
|
||||
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_--
|
||||
@@ -1,16 +1,58 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
# run-tests.sh
|
||||
# description: Generate test emails for Inbucket
|
||||
|
||||
set -eo pipefail
|
||||
[ $TRACE ] && set -x
|
||||
|
||||
# We need to be in swaks-tests directory
|
||||
cmdpath="$(dirname "$0")"
|
||||
if [ "$cmdpath" != "." ]; then
|
||||
cd "$cmdpath"
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
"")
|
||||
to="swaks"
|
||||
;;
|
||||
--*)
|
||||
to="swaks"
|
||||
;;
|
||||
*)
|
||||
to="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
||||
export SWAKS_OPT_server="127.0.0.1:2500"
|
||||
export SWAKS_OPT_to="swaks@inbucket.local"
|
||||
export SWAKS_OPT_to="$to@inbucket.local"
|
||||
|
||||
# Basic test
|
||||
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
|
||||
|
||||
# Multi-recipient test
|
||||
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
|
||||
--body text.txt
|
||||
|
||||
# HTML test
|
||||
swaks $* --h-Subject: "Swaks HTML" --data html.raw
|
||||
swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
|
||||
|
||||
# Top level HTML test
|
||||
swaks $* --h-Subject: "Swaks Top Level HTML" --data nonmime-html.raw
|
||||
|
||||
# Attachment test
|
||||
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png --body text.txt
|
||||
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
|
||||
--body text.txt
|
||||
|
||||
# Encoded subject line test
|
||||
swaks $* --data utf8-subject.txt
|
||||
swaks $* --data utf8-subject.raw
|
||||
|
||||
# Gmail test
|
||||
swaks $* --data gmail.raw
|
||||
|
||||
# Outlook test
|
||||
swaks $* --data outlook.raw
|
||||
|
||||
# Non-mime responsive HTML test
|
||||
swaks $* --data nonmime-html-responsive.raw
|
||||
swaks $* --data nonmime-html-inlined.raw
|
||||
|
||||
29
themes/bootstrap/public/bower.json
Normal file
29
themes/bootstrap/public/bower.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "inbucket-bootstrap",
|
||||
"homepage": "http://www.inbucket.org/",
|
||||
"authors": [
|
||||
"James Hillyerd <james@hillyerd.com>"
|
||||
],
|
||||
"description": "Bootstrap theme for Inbucket",
|
||||
"main": [],
|
||||
"moduleType": [
|
||||
"globals"
|
||||
],
|
||||
"license": "MIT",
|
||||
"ignore": [
|
||||
"**/.*",
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
"test",
|
||||
"tests"
|
||||
],
|
||||
"dependencies": {
|
||||
"bootstrap": "3.3",
|
||||
"jquery": "2",
|
||||
"jquery-color": "^2.1.2",
|
||||
"jquery-sparkline": "^2.1.3",
|
||||
"clipboard": "^1.5.9",
|
||||
"jquery-load-template": "^1.5.6",
|
||||
"moment": "^2.11.2"
|
||||
}
|
||||
}
|
||||
44
themes/bootstrap/public/bower_components/bootstrap/.bower.json
vendored
Normal file
44
themes/bootstrap/public/bower_components/bootstrap/.bower.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "bootstrap",
|
||||
"description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
|
||||
"keywords": [
|
||||
"css",
|
||||
"js",
|
||||
"less",
|
||||
"mobile-first",
|
||||
"responsive",
|
||||
"front-end",
|
||||
"framework",
|
||||
"web"
|
||||
],
|
||||
"homepage": "http://getbootstrap.com",
|
||||
"license": "MIT",
|
||||
"moduleType": "globals",
|
||||
"main": [
|
||||
"less/bootstrap.less",
|
||||
"dist/js/bootstrap.js"
|
||||
],
|
||||
"ignore": [
|
||||
"/.*",
|
||||
"_config.yml",
|
||||
"CNAME",
|
||||
"composer.json",
|
||||
"CONTRIBUTING.md",
|
||||
"docs",
|
||||
"js/tests",
|
||||
"test-infra"
|
||||
],
|
||||
"dependencies": {
|
||||
"jquery": "1.9.1 - 3"
|
||||
},
|
||||
"version": "3.3.7",
|
||||
"_release": "3.3.7",
|
||||
"_resolution": {
|
||||
"type": "version",
|
||||
"tag": "v3.3.7",
|
||||
"commit": "0b9c4a4007c44201dce9a6cc1a38407005c26c86"
|
||||
},
|
||||
"_source": "https://github.com/twbs/bootstrap.git",
|
||||
"_target": "3.3",
|
||||
"_originalSource": "bootstrap"
|
||||
}
|
||||
5
themes/bootstrap/public/bower_components/bootstrap/CHANGELOG.md
vendored
Normal file
5
themes/bootstrap/public/bower_components/bootstrap/CHANGELOG.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Bootstrap uses [GitHub's Releases feature](https://github.com/blog/1547-release-your-software) for its changelogs.
|
||||
|
||||
See [the Releases section of our GitHub project](https://github.com/twbs/bootstrap/releases) for changelogs for each release version of Bootstrap.
|
||||
|
||||
Release announcement posts on [the official Bootstrap blog](http://blog.getbootstrap.com) contain summaries of the most noteworthy changes made in each release.
|
||||
6
themes/bootstrap/public/bower_components/bootstrap/Gemfile
vendored
Normal file
6
themes/bootstrap/public/bower_components/bootstrap/Gemfile
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
group :development, :test do
|
||||
gem 'jekyll', '~> 3.1.2'
|
||||
gem 'jekyll-sitemap', '~> 0.11.0'
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user