mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
Compare commits
120 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 | ||
|
|
1742bf9a34 | ||
|
|
c2779ff054 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -25,10 +25,9 @@ _testmain.go
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# our binary
|
||||
# our binaries
|
||||
/inbucket
|
||||
/inbucket.exe
|
||||
/target/**
|
||||
|
||||
# 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
|
||||
17
.goxc.json
17
.goxc.json
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"ArtifactsDest": "target",
|
||||
"TasksExclude": [
|
||||
"pkg-build"
|
||||
],
|
||||
"Arch": "amd64",
|
||||
"Os": "darwin freebsd linux windows",
|
||||
"ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes",
|
||||
"PackageVersion": "1.1.0",
|
||||
"ConfigVersion": "0.9",
|
||||
"BuildSettings": {
|
||||
"LdFlagsXVars": {
|
||||
"TimeNow": "main.BUILDDATE",
|
||||
"Version": "main.VERSION"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
.travis.yml
16
.travis.yml
@@ -1,9 +1,19 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
env:
|
||||
- DEPLOY_WITH_MAJOR="1.9"
|
||||
|
||||
before_script:
|
||||
- go vet ./...
|
||||
- go get github.com/golang/lint/golint
|
||||
|
||||
go:
|
||||
- 1.6.3
|
||||
- 1.7
|
||||
- 1.9.x
|
||||
- "1.10"
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
script: etc/travis-deploy.sh
|
||||
on:
|
||||
tags: true
|
||||
branch: master
|
||||
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -4,8 +4,60 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
[1.1.0] - 2016-09-03
|
||||
------------
|
||||
## [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)
|
||||
@@ -13,8 +65,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Fixed
|
||||
- Log and continue when unable to delete oldest message during cap enforcement
|
||||
|
||||
[1.1.0-rc2] - 2016-03-06
|
||||
------------------------
|
||||
## [v1.1.0-rc2] - 2016-03-06
|
||||
|
||||
### Added
|
||||
- Message Cap to status page
|
||||
@@ -24,8 +75,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Shutdown hang in retention scanner
|
||||
- Display empty subject as `(No Subject)`
|
||||
|
||||
[1.1.0-rc1] - 2016-03-04
|
||||
------------------------
|
||||
## [v1.1.0-rc1] - 2016-03-04
|
||||
|
||||
### Added
|
||||
- Inbucket now builds with Go 1.5 or 1.6
|
||||
@@ -39,8 +89,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- RESTful API moved to `/api/v1` base URI
|
||||
- More graceful shutdown on Ctrl-C or when errors encountered
|
||||
|
||||
[1.0] - 2014-04-14
|
||||
------------------
|
||||
## [v1.0] - 2014-04-14
|
||||
|
||||
### Added
|
||||
- Add new configuration option `mailbox.message.cap` to prevent individual
|
||||
@@ -48,27 +97,29 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Add Link button to messages, allows for directing another person to a
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
|
||||
[1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
|
||||
[1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
|
||||
[1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
|
||||
[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
|
||||
-----------------
|
||||
## 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. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=snapshot`
|
||||
4. Run: `goxc interpolate-source` to update VERSION var
|
||||
5. Run tests
|
||||
6. Test cross-compile: `goxc`
|
||||
7. Commit changes and merge release: `git flow release finish 1.x.0`
|
||||
8. Upload to bintray: `goxc bintray`
|
||||
9. Update `binary_versions` option in `inbucket-site/_config.yml`
|
||||
- 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
|
||||
@@ -1,7 +1,7 @@
|
||||
# Docker build file for Inbucket, see https://www.docker.io/
|
||||
# Inbucket website: http://www.inbucket.org/
|
||||
|
||||
FROM golang:1.6-alpine
|
||||
FROM golang:1.9-alpine
|
||||
MAINTAINER James Hillyerd, @jameshillyerd
|
||||
|
||||
# Configuration (WORKDIR doesn't support env vars)
|
||||
|
||||
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}
|
||||
28
README.md
28
README.md
@@ -1,28 +1,31 @@
|
||||
Inbucket [][Build Status]
|
||||
========
|
||||
Inbucket
|
||||
=============================================================================
|
||||
[][Build Status]
|
||||
|
||||
Inbucket is an email testing service; it will accept messages for any email
|
||||
address and make them available via web, REST and POP3. Once compiled,
|
||||
Inbucket does not have an external dependencies (HTTP, SMTP, POP3 and storage
|
||||
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
|
||||
are all built in).
|
||||
|
||||
Read more at the [Inbucket Website]
|
||||
|
||||
Development Status
|
||||
------------------
|
||||

|
||||
|
||||
## Development Status
|
||||
|
||||
Inbucket is currently production quality: it is being used for real work.
|
||||
|
||||
Please see the [Change Log] and [Issues List] for more details.
|
||||
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].
|
||||
|
||||
Homebrew Tap
|
||||
------------
|
||||
|
||||
## Homebrew Tap
|
||||
|
||||
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
|
||||
see the `README.md` there for installation instructions.
|
||||
|
||||
Building from Source
|
||||
--------------------
|
||||
|
||||
## Building from Source
|
||||
|
||||
You will need a functioning [Go installation][Google Go] for this to work.
|
||||
|
||||
@@ -41,8 +44,8 @@ the web interface will be available at [localhost:9000](http://localhost:9000/).
|
||||
The Inbucket website has a more complete guide to
|
||||
[installing from source][From Source]
|
||||
|
||||
About
|
||||
-----
|
||||
|
||||
## About
|
||||
|
||||
Inbucket is written in [Google Go]
|
||||
|
||||
@@ -51,6 +54,7 @@ version can be found at https://github.com/jhillyerd/inbucket
|
||||
|
||||
[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/
|
||||
|
||||
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
|
||||
}
|
||||
481
config/config.go
481
config/config.go
@@ -1,17 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/robfig/config"
|
||||
)
|
||||
|
||||
// SMTPConfig contains the SMTP server configuration - not using pointers
|
||||
// so that we can pass around copies of the object safely.
|
||||
// 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
|
||||
@@ -33,13 +33,16 @@ type POP3Config struct {
|
||||
|
||||
// WebConfig contains the HTTP server configuration
|
||||
type WebConfig struct {
|
||||
IP4address net.IP
|
||||
IP4port int
|
||||
TemplateDir string
|
||||
TemplateCache bool
|
||||
PublicDir string
|
||||
GreetingFile string
|
||||
CookieAuthKey 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
|
||||
@@ -50,6 +53,11 @@ type DataStoreConfig struct {
|
||||
MailboxMsgCap int
|
||||
}
|
||||
|
||||
const (
|
||||
missingErrorFmt = "[%v] missing required option %q"
|
||||
parseErrorFmt = "[%v] option %q error: %v"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version of this build, set by main
|
||||
Version = ""
|
||||
@@ -58,13 +66,14 @@ var (
|
||||
BuildDate = ""
|
||||
|
||||
// Config is our global robfig/config object
|
||||
Config *config.Config
|
||||
Config *config.Config
|
||||
logLevel string
|
||||
|
||||
// Parsed specific configs
|
||||
smtpConfig *SMTPConfig
|
||||
pop3Config *POP3Config
|
||||
webConfig *WebConfig
|
||||
dataStoreConfig *DataStoreConfig
|
||||
smtpConfig = &SMTPConfig{}
|
||||
pop3Config = &POP3Config{}
|
||||
webConfig = &WebConfig{}
|
||||
dataStoreConfig = &DataStoreConfig{}
|
||||
)
|
||||
|
||||
// GetSMTPConfig returns a copy of the SmtpConfig object
|
||||
@@ -87,323 +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")
|
||||
requireOption(messages, "datastore", "mailbox.message.cap")
|
||||
|
||||
// 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, str)
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, str)
|
||||
}
|
||||
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, str)
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, str)
|
||||
}
|
||||
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, str)
|
||||
}
|
||||
addr = addr.To4()
|
||||
if addr == nil {
|
||||
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, str)
|
||||
}
|
||||
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
|
||||
|
||||
option = "cookie.auth.key"
|
||||
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)
|
||||
}
|
||||
webConfig.CookieAuthKey = 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)
|
||||
}
|
||||
option = "mailbox.message.cap"
|
||||
dataStoreConfig.MailboxMsgCap, 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package smtpd
|
||||
// Package datastore contains implementation independent datastore logic
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -6,7 +7,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,6 +30,7 @@ type Mailbox interface {
|
||||
GetMessage(id string) (Message, error)
|
||||
Purge() error
|
||||
NewMessage() (Message, error)
|
||||
Name() string
|
||||
String() string
|
||||
}
|
||||
|
||||
@@ -36,11 +38,12 @@ type Mailbox interface {
|
||||
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.MIMEBody, err error)
|
||||
ReadBody() (body *enmime.Envelope, err error)
|
||||
ReadRaw() (raw *string, err error)
|
||||
Append(data []byte) error
|
||||
Close() error
|
||||
@@ -1,4 +1,4 @@
|
||||
package smtpd
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
@@ -14,11 +14,6 @@ var (
|
||||
retentionScanCompleted = time.Now()
|
||||
retentionScanCompletedMu sync.RWMutex
|
||||
|
||||
// Indicates Inbucket needs to shut down
|
||||
globalShutdown chan bool
|
||||
// Indicates the retention scanner has shut down
|
||||
retentionShutdown chan bool
|
||||
|
||||
// History counters
|
||||
expRetentionDeletesTotal = new(expvar.Int)
|
||||
expRetentionPeriod = new(expvar.Int)
|
||||
@@ -33,73 +28,105 @@ var (
|
||||
expRetainedHist = new(expvar.String)
|
||||
)
|
||||
|
||||
// StartRetentionScanner launches a go-routine that scans for expired
|
||||
// messages, following the configured interval
|
||||
func StartRetentionScanner(ds DataStore, shutdownChannel chan bool) {
|
||||
globalShutdown = shutdownChannel
|
||||
retentionShutdown = make(chan bool)
|
||||
cfg := config.GetDataStoreConfig()
|
||||
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
|
||||
if cfg.RetentionMinutes > 0 {
|
||||
// Retention scanning enabled
|
||||
log.Infof("Retention configured for %v minutes", cfg.RetentionMinutes)
|
||||
go retentionScanner(ds, time.Duration(cfg.RetentionMinutes)*time.Minute,
|
||||
time.Duration(cfg.RetentionSleep)*time.Millisecond)
|
||||
} else {
|
||||
log.Infof("Retention scanner disabled")
|
||||
close(retentionShutdown)
|
||||
}
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
func retentionScanner(ds DataStore, maxAge time.Duration, sleep time.Duration) {
|
||||
// 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 running more than once a minute
|
||||
// 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 _ = <-globalShutdown:
|
||||
case <-rs.globalShutdown:
|
||||
break retentionLoop
|
||||
case _ = <-time.After(dur):
|
||||
case <-time.After(dur):
|
||||
}
|
||||
}
|
||||
|
||||
// Kickoff scan
|
||||
start = time.Now()
|
||||
if err := doRetentionScan(ds, maxAge, sleep); err != nil {
|
||||
if err := rs.doScan(); err != nil {
|
||||
log.Errorf("Error during retention scan: %v", err)
|
||||
}
|
||||
|
||||
// Check for global shutdown
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
case <-rs.globalShutdown:
|
||||
break retentionLoop
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
log.Tracef("Retention scanner shut down")
|
||||
close(retentionShutdown)
|
||||
close(rs.retentionShutdown)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 * maxAge)
|
||||
mboxes, err := ds.AllMailboxes()
|
||||
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())
|
||||
@@ -114,56 +141,40 @@ func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) er
|
||||
retained++
|
||||
}
|
||||
}
|
||||
// Check for shutdown
|
||||
// Sleep after completing a mailbox
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
case <-rs.globalShutdown:
|
||||
log.Tracef("Retention scan aborted due to shutdown")
|
||||
return nil
|
||||
default:
|
||||
case <-time.After(rs.retentionSleep):
|
||||
// Reduce disk thrashing
|
||||
}
|
||||
// Sleep after completing a mailbox
|
||||
time.Sleep(sleep)
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
setRetentionScanCompleted(time.Now())
|
||||
expRetainedCurrent.Set(int64(retained))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetentionJoin does not retun until the retention scanner has shut down
|
||||
func RetentionJoin() {
|
||||
if retentionShutdown != nil {
|
||||
select {
|
||||
case _ = <-retentionShutdown:
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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,181 +1,156 @@
|
||||
package smtpd
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"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
|
||||
if err := doRetentionScan(mds, 4*time.Hour-time.Minute, 0); 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// NewMessage mock function
|
||||
func (m *MockMailbox) NewMessage() (Message, error) {
|
||||
args := m.Called()
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -73,6 +73,10 @@ 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=%(install.dir)s/themes/%(theme)s/templates
|
||||
|
||||
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
|
||||
# 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]
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ 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
|
||||
|
||||
@@ -93,6 +97,18 @@ greeting.file=/con/configuration/greeting.html
|
||||
# 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]
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@ apk add --no-cache --virtual .build-deps git
|
||||
|
||||
# Setup
|
||||
export GOBIN="$bindir"
|
||||
builddate="$(date -Iseconds)"
|
||||
cd "$srcdir"
|
||||
go clean
|
||||
# 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 ./...
|
||||
|
||||
@@ -27,7 +30,7 @@ echo "### Testing Inbucket"
|
||||
go test ./...
|
||||
|
||||
echo "### Building Inbucket"
|
||||
go build -o inbucket -ldflags "-X 'main.BUILDDATE=$builddate'" -v .
|
||||
go build -o inbucket -ldflags "-X 'main.version=$buildver' -X 'main.date=$builddate'" -v .
|
||||
|
||||
echo "### Installing Inbucket"
|
||||
set -x
|
||||
|
||||
@@ -75,6 +75,10 @@ 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
|
||||
|
||||
@@ -93,6 +97,18 @@ greeting.file=%(themes.dir)s/greeting.html
|
||||
# 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]
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ 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=%(install.dir)s/themes/%(theme)s/templates
|
||||
|
||||
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
|
||||
# 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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -103,7 +103,7 @@ main() {
|
||||
esac
|
||||
|
||||
# Use jq to pretty-print if installed and we are expecting JSON output
|
||||
if [ $pretty ] && [ $is_json ] && type -P jq; then
|
||||
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"
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -73,6 +73,10 @@ ip4.port=80
|
||||
# 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
|
||||
|
||||
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
|
||||
# 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]
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ 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=%(install.dir)s\themes\%(theme)s\templates
|
||||
|
||||
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s\themes\greeting.html
|
||||
# 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]
|
||||
|
||||
|
||||
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,4 +1,4 @@
|
||||
package smtpd
|
||||
package filestore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -458,6 +458,55 @@ func TestFSNoMessageCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
|
||||
path, err := ioutil.TempDir("", "inbucket")
|
||||
@@ -6,14 +6,18 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"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
|
||||
DataStore datastore.DataStore
|
||||
MsgHub *msghub.Hub
|
||||
WebConfig config.WebConfig
|
||||
IsJSON bool
|
||||
}
|
||||
|
||||
@@ -56,6 +60,8 @@ func NewContext(req *http.Request) (*Context, error) {
|
||||
Vars: vars,
|
||||
Session: sess,
|
||||
DataStore: DataStore,
|
||||
MsgHub: msgHub,
|
||||
WebConfig: webConfig,
|
||||
IsJSON: headerMatch(req, "Accept", "application/json"),
|
||||
}
|
||||
return ctx, err
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
package httpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/goods/httpbuf"
|
||||
"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/smtpd"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
// Handler is a function type that handles an HTTP request in Inbucket
|
||||
@@ -21,7 +23,10 @@ type Handler func(http.ResponseWriter, *http.Request, *Context) error
|
||||
|
||||
var (
|
||||
// DataStore is where all the mailboxes and messages live
|
||||
DataStore smtpd.DataStore
|
||||
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
|
||||
@@ -32,15 +37,29 @@ var (
|
||||
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, ds smtpd.DataStore, shutdownChan chan bool) {
|
||||
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)
|
||||
@@ -60,7 +79,7 @@ func Initialize(cfg config.WebConfig, ds smtpd.DataStore, shutdownChan chan bool
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests
|
||||
func Start() {
|
||||
func Start(ctx context.Context) {
|
||||
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
|
||||
server = &http.Server{
|
||||
Addr: addr,
|
||||
@@ -80,11 +99,11 @@ func Start() {
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go serve()
|
||||
go serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
case _ = <-ctx.Done():
|
||||
log.Tracef("HTTP server shutting down on request")
|
||||
}
|
||||
|
||||
@@ -95,12 +114,12 @@ func Start() {
|
||||
}
|
||||
|
||||
// serve begins serving HTTP requests
|
||||
func serve() {
|
||||
func serve(ctx context.Context) {
|
||||
// server.Serve blocks until we close the listener
|
||||
err := server.Serve(listener)
|
||||
|
||||
select {
|
||||
case _ = <-globalShutdown:
|
||||
case _ = <-ctx.Done():
|
||||
// Nop
|
||||
default:
|
||||
log.Errorf("HTTP server failed: %v", err)
|
||||
@@ -121,26 +140,13 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
defer ctx.Close()
|
||||
|
||||
// Run the handler, grab the error, and report it
|
||||
buf := new(httpbuf.Buffer)
|
||||
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
|
||||
err = h(buf, req, ctx)
|
||||
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
|
||||
}
|
||||
|
||||
// Save the session
|
||||
if err = ctx.Session.Save(req, buf); err != nil {
|
||||
log.Errorf("HTTP failed to save session: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply the buffered response to the writer
|
||||
if _, err = buf.Apply(w); err != nil {
|
||||
log.Errorf("HTTP failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func emergencyShutdown() {
|
||||
|
||||
80
inbucket.go
80
inbucket.go
@@ -2,17 +2,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"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"
|
||||
@@ -20,20 +24,17 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// VERSION contains the build version number, populated during linking by goxc
|
||||
VERSION = "1.1.0"
|
||||
// version contains the build version number, populated during linking
|
||||
version = "undefined"
|
||||
|
||||
// BUILDDATE contains the build date, populated during linking by goxc
|
||||
BUILDDATE = "undefined"
|
||||
// date contains the build date, populated during linking
|
||||
date = "undefined"
|
||||
|
||||
// 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")
|
||||
|
||||
// startTime is used to calculate uptime of Inbucket
|
||||
startTime = time.Now()
|
||||
|
||||
// shutdownChan - close it to tell Inbucket to shut down cleanly
|
||||
shutdownChan = make(chan bool)
|
||||
|
||||
@@ -42,9 +43,27 @@ var (
|
||||
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 = BUILDDATE
|
||||
config.Version = version
|
||||
config.BuildDate = date
|
||||
|
||||
flag.Parse()
|
||||
if *help {
|
||||
@@ -52,6 +71,9 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Root context
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
|
||||
// Load & Parse config
|
||||
if flag.NArg() != 1 {
|
||||
flag.Usage()
|
||||
@@ -64,12 +86,11 @@ func main() {
|
||||
}
|
||||
|
||||
// Setup signal handler
|
||||
sigChan := make(chan os.Signal)
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
|
||||
|
||||
// Initialize logging
|
||||
level, _ := config.Config.String("logging", "level")
|
||||
log.SetLogLevel(level)
|
||||
log.SetLogLevel(config.GetLogLevel())
|
||||
if err := log.Initialize(*logfile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v", err)
|
||||
os.Exit(1)
|
||||
@@ -91,23 +112,25 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create message hub
|
||||
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
|
||||
|
||||
// Grab our datastore
|
||||
ds := smtpd.DefaultFileDataStore()
|
||||
ds := filestore.DefaultFileDataStore()
|
||||
|
||||
// Start HTTP server
|
||||
httpd.Initialize(config.GetWebConfig(), ds, shutdownChan)
|
||||
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
|
||||
webui.SetupRoutes(httpd.Router)
|
||||
rest.SetupRoutes(httpd.Router)
|
||||
go httpd.Start()
|
||||
go httpd.Start(rootCtx)
|
||||
|
||||
// Start POP3 server
|
||||
// TODO pass datastore
|
||||
pop3Server = pop3d.New(shutdownChan)
|
||||
go pop3Server.Start()
|
||||
pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
|
||||
go pop3Server.Start(rootCtx)
|
||||
|
||||
// Startup SMTP server
|
||||
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), ds, shutdownChan)
|
||||
go smtpServer.Start()
|
||||
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
|
||||
go smtpServer.Start(rootCtx)
|
||||
|
||||
// Loop forever waiting for signals or shutdown channel
|
||||
signalLoop:
|
||||
@@ -127,7 +150,8 @@ signalLoop:
|
||||
log.Infof("Received SIGTERM, shutting down")
|
||||
close(shutdownChan)
|
||||
}
|
||||
case _ = <-shutdownChan:
|
||||
case <-shutdownChan:
|
||||
rootCancel()
|
||||
break signalLoop
|
||||
}
|
||||
}
|
||||
@@ -157,17 +181,3 @@ func timedExit() {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
// State tracks the current mode of our POP3 state machine
|
||||
@@ -57,18 +57,18 @@ var commands = map[string]bool{
|
||||
|
||||
// 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
|
||||
@@ -432,7 +432,7 @@ 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()
|
||||
if err != nil {
|
||||
ses.logError("Failed to read message for RETR command")
|
||||
@@ -465,7 +465,7 @@ 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()
|
||||
if err != nil {
|
||||
ses.logError("Failed to read message for RETR command")
|
||||
|
||||
@@ -1,54 +1,45 @@
|
||||
package pop3d
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
// 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
|
||||
globalShutdown chan bool
|
||||
localShutdown chan bool
|
||||
waitgroup *sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a new Server struct
|
||||
func New(shutdownChan chan bool) *Server {
|
||||
// Get a new instance of the the FileDataStore - the locking and counting
|
||||
// mechanisms are both global variables in the smtpd package. If that
|
||||
// changes in the future, this should be modified to use the same DataStore
|
||||
// instance.
|
||||
ds := smtpd.DefaultFileDataStore()
|
||||
cfg := config.GetPOP3Config()
|
||||
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,
|
||||
localShutdown: make(chan bool),
|
||||
waitgroup: new(sync.WaitGroup),
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server and listen for connections
|
||||
func (s *Server) Start() {
|
||||
cfg := config.GetPOP3Config()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
|
||||
cfg.IP4address, cfg.IP4port))
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
addr, err := net.ResolveTCPAddr("tcp4", s.host)
|
||||
if err != nil {
|
||||
log.Errorf("POP3 Failed to build tcp4 address: %v", err)
|
||||
// serve() never called, so we do local shutdown here
|
||||
close(s.localShutdown)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
@@ -57,18 +48,16 @@ func (s *Server) Start() {
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
log.Errorf("POP3 failed to start tcp4 listener: %v", err)
|
||||
// serve() never called, so we do local shutdown here
|
||||
close(s.localShutdown)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Listener go routine
|
||||
go s.serve()
|
||||
go s.serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-s.globalShutdown:
|
||||
case _ = <-ctx.Done():
|
||||
}
|
||||
|
||||
log.Tracef("POP3 shutdown requested, connections will be drained")
|
||||
@@ -79,7 +68,7 @@ func (s *Server) Start() {
|
||||
}
|
||||
|
||||
// serve is the listen/accept loop
|
||||
func (s *Server) serve() {
|
||||
func (s *Server) serve(ctx context.Context) {
|
||||
// Handle incoming connections
|
||||
var tempDelay time.Duration
|
||||
for sid := 1; ; sid++ {
|
||||
@@ -100,11 +89,11 @@ func (s *Server) serve() {
|
||||
} else {
|
||||
// Permanent error
|
||||
select {
|
||||
case _ = <-s.globalShutdown:
|
||||
close(s.localShutdown)
|
||||
case <-ctx.Done():
|
||||
// POP3 is shutting down
|
||||
return
|
||||
default:
|
||||
close(s.localShutdown)
|
||||
// Something went wrong
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
@@ -128,10 +117,6 @@ func (s *Server) emergencyShutdown() {
|
||||
|
||||
// Drain causes the caller to block until all active POP3 sessions have finished
|
||||
func (s *Server) Drain() {
|
||||
// Wait for listener to exit
|
||||
select {
|
||||
case _ = <-s.localShutdown:
|
||||
}
|
||||
// Wait for sessions to close
|
||||
s.waitgroup.Wait()
|
||||
log.Tracef("POP3 connections have drained")
|
||||
|
||||
@@ -4,46 +4,23 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"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/smtpd"
|
||||
"github.com/jhillyerd/inbucket/rest/model"
|
||||
"github.com/jhillyerd/inbucket/stringutil"
|
||||
)
|
||||
|
||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
||||
type JSONMessageHeaderV1 struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
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"`
|
||||
Subject string `json:"subject"`
|
||||
Date time.Time `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
Body *JSONMessageBodyV1 `json:"body"`
|
||||
Header mail.Header `json:"header"`
|
||||
}
|
||||
|
||||
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
|
||||
type JSONMessageBodyV1 struct {
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
|
||||
// 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 := smtpd.ParseMailboxName(ctx.Vars["name"])
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,12 +36,13 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
|
||||
}
|
||||
log.Tracef("Got %v messsages", len(messages))
|
||||
|
||||
jmessages := make([]*JSONMessageHeaderV1, len(messages))
|
||||
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
|
||||
for i, msg := range messages {
|
||||
jmessages[i] = &JSONMessageHeaderV1{
|
||||
jmessages[i] = &model.JSONMessageHeaderV1{
|
||||
Mailbox: name,
|
||||
ID: msg.ID(),
|
||||
From: msg.From(),
|
||||
To: msg.To(),
|
||||
Subject: msg.Subject(),
|
||||
Date: msg.Date(),
|
||||
Size: msg.Size(),
|
||||
@@ -77,7 +55,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
|
||||
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 := smtpd.ParseMailboxName(ctx.Vars["name"])
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +65,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
msg, err := mb.GetMessage(id)
|
||||
if err == smtpd.ErrNotExist {
|
||||
if err == datastore.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
@@ -104,26 +82,42 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
|
||||
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,
|
||||
&JSONMessageV1{
|
||||
&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: &JSONMessageBodyV1{
|
||||
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 := smtpd.ParseMailboxName(ctx.Vars["name"])
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -146,7 +140,7 @@ func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context
|
||||
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 := smtpd.ParseMailboxName(ctx.Vars["name"])
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,7 +150,7 @@ func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
message, err := mb.GetMessage(id)
|
||||
if err == smtpd.ErrNotExist {
|
||||
if err == datastore.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
@@ -176,11 +170,11 @@ func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex
|
||||
return nil
|
||||
}
|
||||
|
||||
// MailboxDeleteV1 removes a particular message from a mailbox. Renders JSON or plain/text OK
|
||||
// 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 := smtpd.ParseMailboxName(ctx.Vars["name"])
|
||||
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -190,7 +184,7 @@ func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex
|
||||
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
|
||||
}
|
||||
message, err := mb.GetMessage(id)
|
||||
if err == smtpd.ErrNotExist {
|
||||
if err == datastore.ErrNotExist {
|
||||
http.NotFound(w, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
mailboxKey = "mailbox"
|
||||
idKey = "id"
|
||||
fromKey = "from"
|
||||
toKey = "to"
|
||||
subjectKey = "subject"
|
||||
dateKey = "date"
|
||||
sizeKey = "size"
|
||||
@@ -30,7 +31,7 @@ const (
|
||||
|
||||
func TestRestMailboxList(t *testing.T) {
|
||||
// Setup
|
||||
ds := &MockDataStore{}
|
||||
ds := &datastore.MockDataStore{}
|
||||
logbuf := setupWebServer(ds)
|
||||
|
||||
// Test invalid mailbox name
|
||||
@@ -44,9 +45,9 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test empty mailbox
|
||||
emptybox := &MockMailbox{}
|
||||
emptybox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "empty").Return(emptybox, nil)
|
||||
emptybox.On("GetMessages").Return([]smtpd.Message{}, nil)
|
||||
emptybox.On("GetMessages").Return([]datastore.Message{}, nil)
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty")
|
||||
expectCode = 200
|
||||
@@ -58,7 +59,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test MailboxFor error
|
||||
ds.On("MailboxFor", "error").Return(&MockMailbox{}, fmt.Errorf("Internal error"))
|
||||
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
|
||||
w, err = testRestGet(baseURL + "/mailbox/error")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
@@ -76,9 +77,9 @@ func TestRestMailboxList(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test MailboxFor error
|
||||
error2box := &MockMailbox{}
|
||||
error2box := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "error2").Return(error2box, nil)
|
||||
error2box.On("GetMessages").Return([]smtpd.Message{}, fmt.Errorf("Internal error 2"))
|
||||
error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2"))
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/error2")
|
||||
expectCode = 500
|
||||
@@ -94,6 +95,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
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)),
|
||||
}
|
||||
@@ -101,14 +103,15 @@ func TestRestMailboxList(t *testing.T) {
|
||||
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 := &MockMailbox{}
|
||||
goodbox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "good").Return(goodbox, nil)
|
||||
msg1 := data1.MockMessage()
|
||||
msg2 := data2.MockMessage()
|
||||
goodbox.On("GetMessages").Return([]smtpd.Message{msg1, msg2}, nil)
|
||||
goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil)
|
||||
|
||||
// Check return code
|
||||
w, err = testRestGet(baseURL + "/mailbox/good")
|
||||
@@ -152,7 +155,7 @@ func TestRestMailboxList(t *testing.T) {
|
||||
|
||||
func TestRestMessage(t *testing.T) {
|
||||
// Setup
|
||||
ds := &MockDataStore{}
|
||||
ds := &datastore.MockDataStore{}
|
||||
logbuf := setupWebServer(ds)
|
||||
|
||||
// Test invalid mailbox name
|
||||
@@ -166,9 +169,9 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test requesting a message that does not exist
|
||||
emptybox := &MockMailbox{}
|
||||
emptybox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "empty").Return(emptybox, nil)
|
||||
emptybox.On("GetMessage", "0001").Return(&MockMessage{}, smtpd.ErrNotExist)
|
||||
emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist)
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
|
||||
expectCode = 404
|
||||
@@ -180,7 +183,7 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test MailboxFor error
|
||||
ds.On("MailboxFor", "error").Return(&MockMailbox{}, fmt.Errorf("Internal error"))
|
||||
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
|
||||
w, err = testRestGet(baseURL + "/mailbox/error/0001")
|
||||
expectCode = 500
|
||||
if err != nil {
|
||||
@@ -198,9 +201,9 @@ func TestRestMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test GetMessage error
|
||||
error2box := &MockMailbox{}
|
||||
error2box := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "error2").Return(error2box, nil)
|
||||
error2box.On("GetMessage", "0001").Return(&MockMessage{}, fmt.Errorf("Internal error 2"))
|
||||
error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2"))
|
||||
|
||||
w, err = testRestGet(baseURL + "/mailbox/error2/0001")
|
||||
expectCode = 500
|
||||
@@ -225,7 +228,7 @@ func TestRestMessage(t *testing.T) {
|
||||
Text: "This is some text",
|
||||
HTML: "This is some HTML",
|
||||
}
|
||||
goodbox := &MockMailbox{}
|
||||
goodbox := &datastore.MockMailbox{}
|
||||
ds.On("MailboxFor", "good").Return(goodbox, nil)
|
||||
msg1 := data1.MockMessage()
|
||||
goodbox.On("GetMessage", "0001").Return(msg1, nil)
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -6,9 +6,18 @@ 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/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
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// Mock DataStore object
|
||||
type MockDataStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockDataStore) MailboxFor(name string) (smtpd.Mailbox, error) {
|
||||
args := m.Called(name)
|
||||
return args.Get(0).(smtpd.Mailbox), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockDataStore) AllMailboxes() ([]smtpd.Mailbox, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]smtpd.Mailbox), args.Error(1)
|
||||
}
|
||||
|
||||
// Mock Mailbox object
|
||||
type MockMailbox struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockMailbox) GetMessages() ([]smtpd.Message, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]smtpd.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) GetMessage(id string) (smtpd.Message, error) {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(smtpd.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) Purge() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) NewMessage() (smtpd.Message, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(smtpd.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) String() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// Mock Message object
|
||||
type MockMessage struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockMessage) ID() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockMessage) From() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockMessage) Date() time.Time {
|
||||
args := m.Called()
|
||||
return args.Get(0).(time.Time)
|
||||
}
|
||||
|
||||
func (m *MockMessage) Subject() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
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) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(*enmime.MIMEBody), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMessage) ReadRaw() (raw *string, err error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(*string), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(io.ReadCloser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMessage) Size() int64 {
|
||||
args := m.Called()
|
||||
return int64(args.Int(0))
|
||||
}
|
||||
|
||||
func (m *MockMessage) Append(data []byte) error {
|
||||
// []byte arg seems to mess up testify/mock
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMessage) Close() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockMessage) Delete() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockMessage) String() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
@@ -9,24 +9,27 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"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() *MockMessage {
|
||||
msg := &MockMessage{}
|
||||
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)
|
||||
@@ -34,7 +37,7 @@ func (d *InputMessageData) MockMessage() *MockMessage {
|
||||
Header: d.Header,
|
||||
}
|
||||
msg.On("ReadHeader").Return(gomsg, nil)
|
||||
body := &enmime.MIMEBody{
|
||||
body := &enmime.Envelope{
|
||||
Text: d.Text,
|
||||
HTML: d.HTML,
|
||||
}
|
||||
@@ -79,6 +82,11 @@ func (d *InputMessageData) CompareToJSONHeaderMap(json interface{}) (errors []st
|
||||
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)
|
||||
}
|
||||
@@ -180,7 +188,7 @@ func testRestGet(url string) (*httptest.ResponseRecorder, error) {
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func setupWebServer(ds smtpd.DataStore) *bytes.Buffer {
|
||||
func setupWebServer(ds datastore.DataStore) *bytes.Buffer {
|
||||
// Capture log output
|
||||
buf := new(bytes.Buffer)
|
||||
log.SetOutput(buf)
|
||||
@@ -188,11 +196,11 @@ func setupWebServer(ds smtpd.DataStore) *bytes.Buffer {
|
||||
// Have to reset default mux to prevent duplicate routes
|
||||
http.DefaultServeMux = http.NewServeMux()
|
||||
cfg := config.WebConfig{
|
||||
TemplateDir: "../themes/integral/templates",
|
||||
PublicDir: "../themes/integral/public",
|
||||
TemplateDir: "../themes/bootstrap/templates",
|
||||
PublicDir: "../themes/bootstrap/public",
|
||||
}
|
||||
shutdownChan := make(chan bool)
|
||||
httpd.Initialize(cfg, ds, shutdownChan)
|
||||
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,539 +0,0 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
)
|
||||
|
||||
// Name of index file in each mailbox
|
||||
const indexFileName = "index.gob"
|
||||
|
||||
var (
|
||||
// indexLock 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
|
||||
indexLock = new(sync.RWMutex)
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
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) (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, 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() ([]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, 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
|
||||
}
|
||||
|
||||
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, 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
|
||||
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.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
|
||||
}
|
||||
|
||||
// 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.Errorf("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 func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Errorf("Failed to close %q: %v", mb.indexPath, 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// No messages, delete index+maildir
|
||||
log.Tracef("Removing mailbox %v", mb.path)
|
||||
return os.RemoveAll(mb.path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
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() (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
|
||||
}
|
||||
|
||||
// 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.MIMEBody, 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)
|
||||
msg, err := mail.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mime, err := enmime.ParseMIMEBody(msg)
|
||||
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 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 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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
159
smtpd/handler.go
159
smtpd/handler.go
@@ -12,7 +12,10 @@ import (
|
||||
"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
|
||||
@@ -67,6 +70,12 @@ var commands = map[string]bool{
|
||||
"TURN": true,
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -258,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
|
||||
@@ -307,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
|
||||
@@ -341,17 +350,13 @@ func (ss *Session) mailHandler(cmd string, arg string) {
|
||||
|
||||
// DATA
|
||||
func (ss *Session) dataHandler() {
|
||||
// Timestamp for Received header
|
||||
stamp := time.Now().Format(timeStampFormat)
|
||||
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))
|
||||
@@ -367,35 +372,19 @@ func (ss *Session) dataHandler() {
|
||||
ss.reset()
|
||||
return
|
||||
}
|
||||
mailboxes[i] = mb
|
||||
if messages[i], err = mb.NewMessage(); err != nil {
|
||||
ss.logError("Failed to create message for %q: %s", local, err)
|
||||
ss.send(fmt.Sprintf("451 Failed to create message for %v", local))
|
||||
ss.reset()
|
||||
return
|
||||
}
|
||||
|
||||
// Generate Received header
|
||||
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
|
||||
ss.remoteDomain, ss.remoteHost, ss.server.domain, recip, stamp)
|
||||
if err := messages[i].Append([]byte(recd)); err != nil {
|
||||
ss.logError("Failed to write received header for %q: %s", local, err)
|
||||
ss.send(fmt.Sprintf("451 Failed to create message for %v", local))
|
||||
ss.reset()
|
||||
return
|
||||
}
|
||||
recipients = append(recipients, recipientDetails{recip, local, domain, mb})
|
||||
} else {
|
||||
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() {
|
||||
@@ -406,20 +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 {
|
||||
// This logic should be updated to report failures
|
||||
// writing the initial message file to the client
|
||||
// after we implement a single-store system (issue
|
||||
// #23)
|
||||
ss.logError("Error: %v while writing message", err)
|
||||
}
|
||||
// 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 {
|
||||
@@ -434,6 +423,8 @@ 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
|
||||
@@ -443,21 +434,52 @@ func (ss *Session) dataHandler() {
|
||||
// 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()
|
||||
// Should really cleanup the crap on filesystem (after issue #23)
|
||||
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) {
|
||||
@@ -490,33 +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
|
||||
}
|
||||
if _, err = buf.Write(line); err != nil {
|
||||
return err
|
||||
}
|
||||
// Read the next byte looking for '\n'
|
||||
c, err := ss.reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = buf.WriteByte(c); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
@@ -565,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")
|
||||
|
||||
@@ -2,16 +2,20 @@ package smtpd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"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,13 @@ 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},
|
||||
@@ -83,17 +83,13 @@ func TestGreetState(t *testing.T) {
|
||||
// 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},
|
||||
@@ -148,20 +144,25 @@ func TestReadyState(t *testing.T) {
|
||||
// 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)
|
||||
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},
|
||||
@@ -258,15 +259,22 @@ func TestMailState(t *testing.T) {
|
||||
// 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)
|
||||
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)
|
||||
@@ -359,7 +367,7 @@ 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),
|
||||
@@ -373,12 +381,18 @@ 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
|
||||
shutdownChan := make(chan bool)
|
||||
return NewServer(cfg, ds, shutdownChan), buf
|
||||
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
|
||||
@@ -393,7 +407,3 @@ func setupSMTPSession(server *Server) net.Conn {
|
||||
|
||||
return clientConn
|
||||
}
|
||||
|
||||
func teardownSMTPServer(server *Server) {
|
||||
//log.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package smtpd
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -10,28 +11,51 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/datastore"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m := expvar.NewMap("smtp")
|
||||
m.Set("ConnectsTotal", expConnectsTotal)
|
||||
m.Set("ConnectsHist", expConnectsHist)
|
||||
m.Set("ConnectsCurrent", expConnectsCurrent)
|
||||
m.Set("ReceivedTotal", expReceivedTotal)
|
||||
m.Set("ReceivedHist", expReceivedHist)
|
||||
m.Set("ErrorsTotal", expErrorsTotal)
|
||||
m.Set("ErrorsHist", expErrorsHist)
|
||||
m.Set("WarnsTotal", expWarnsTotal)
|
||||
m.Set("WarnsHist", expWarnsHist)
|
||||
|
||||
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
|
||||
dataStore DataStore
|
||||
storeMessages bool
|
||||
listener net.Listener
|
||||
|
||||
// globalShutdown is the signal Inbucket needs to shut down
|
||||
globalShutdown chan 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
|
||||
|
||||
// localShutdown indicates this component has completed shutting down
|
||||
localShutdown chan bool
|
||||
|
||||
// waitgroup tracks individual sessions
|
||||
waitgroup *sync.WaitGroup
|
||||
// State
|
||||
listener net.Listener // Incoming network connections
|
||||
waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -56,30 +80,32 @@ var (
|
||||
)
|
||||
|
||||
// NewServer creates a new Server instance with the specificed config
|
||||
func NewServer(cfg config.SMTPConfig, ds DataStore, globalShutdown chan bool) *Server {
|
||||
func NewServer(
|
||||
cfg config.SMTPConfig,
|
||||
globalShutdown chan bool,
|
||||
ds datastore.DataStore,
|
||||
msgHub *msghub.Hub) *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),
|
||||
globalShutdown: globalShutdown,
|
||||
localShutdown: make(chan bool),
|
||||
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() {
|
||||
cfg := config.GetSMTPConfig()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
|
||||
cfg.IP4address, cfg.IP4port))
|
||||
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)
|
||||
// serve() never called, so we do local shutdown here
|
||||
close(s.localShutdown)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
@@ -88,8 +114,6 @@ func (s *Server) Start() {
|
||||
s.listener, err = net.ListenTCP("tcp4", addr)
|
||||
if err != nil {
|
||||
log.Errorf("SMTP failed to start tcp4 listener: %v", err)
|
||||
// serve() never called, so we do local shutdown here
|
||||
close(s.localShutdown)
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
@@ -101,16 +125,14 @@ func (s *Server) Start() {
|
||||
}
|
||||
|
||||
// Start retention scanner
|
||||
StartRetentionScanner(s.dataStore, s.globalShutdown)
|
||||
s.retentionScanner.Start()
|
||||
|
||||
// Listener go routine
|
||||
go s.serve()
|
||||
go s.serve(ctx)
|
||||
|
||||
// Wait for shutdown
|
||||
select {
|
||||
case _ = <-s.globalShutdown:
|
||||
log.Tracef("SMTP shutdown requested, connections will be drained")
|
||||
}
|
||||
<-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 {
|
||||
@@ -119,7 +141,7 @@ func (s *Server) Start() {
|
||||
}
|
||||
|
||||
// serve is the listen/accept loop
|
||||
func (s *Server) serve() {
|
||||
func (s *Server) serve(ctx context.Context) {
|
||||
// Handle incoming connections
|
||||
var tempDelay time.Duration
|
||||
for sessionID := 1; ; sessionID++ {
|
||||
@@ -141,11 +163,11 @@ func (s *Server) serve() {
|
||||
} else {
|
||||
// Permanent error
|
||||
select {
|
||||
case _ = <-s.globalShutdown:
|
||||
close(s.localShutdown)
|
||||
case <-ctx.Done():
|
||||
// SMTP is shutting down
|
||||
return
|
||||
default:
|
||||
close(s.localShutdown)
|
||||
// Something went wrong
|
||||
s.emergencyShutdown()
|
||||
return
|
||||
}
|
||||
@@ -162,7 +184,7 @@ func (s *Server) serve() {
|
||||
func (s *Server) emergencyShutdown() {
|
||||
// Shutdown Inbucket
|
||||
select {
|
||||
case _ = <-s.globalShutdown:
|
||||
case <-s.globalShutdown:
|
||||
default:
|
||||
close(s.globalShutdown)
|
||||
}
|
||||
@@ -170,53 +192,8 @@ func (s *Server) emergencyShutdown() {
|
||||
|
||||
// Drain causes the caller to block until all active SMTP sessions have finished
|
||||
func (s *Server) Drain() {
|
||||
// Wait for listener to exit
|
||||
select {
|
||||
case _ = <-s.localShutdown:
|
||||
}
|
||||
// Wait for sessions to close
|
||||
s.waitgroup.Wait()
|
||||
log.Tracef("SMTP connections have drained")
|
||||
RetentionJoin()
|
||||
}
|
||||
|
||||
// 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)
|
||||
m.Set("ConnectsHist", expConnectsHist)
|
||||
m.Set("ConnectsCurrent", expConnectsCurrent)
|
||||
m.Set("ReceivedTotal", expReceivedTotal)
|
||||
m.Set("ReceivedHist", expReceivedHist)
|
||||
m.Set("ErrorsTotal", expErrorsTotal)
|
||||
m.Set("ErrorsHist", expErrorsHist)
|
||||
m.Set("WarnsTotal", expWarnsTotal)
|
||||
m.Set("WarnsHist", expWarnsHist)
|
||||
|
||||
t := time.NewTicker(time.Minute)
|
||||
go metricsTicker(t)
|
||||
s.retentionScanner.Join()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package smtpd
|
||||
package stringutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -42,7 +41,7 @@ func ParseMailboxName(localPart string) (result string, err error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HashMailboxName accepts a mailbox name and hashes it. Inbucket uses this as
|
||||
// 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()
|
||||
@@ -53,18 +52,6 @@ func HashMailboxName(mailbox string) string {
|
||||
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 {
|
||||
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, ",")
|
||||
}
|
||||
|
||||
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
|
||||
func ValidateDomainPart(domain string) bool {
|
||||
if len(domain) == 0 {
|
||||
@@ -143,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
|
||||
@@ -159,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
|
||||
@@ -178,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")
|
||||
@@ -195,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,4 +1,4 @@
|
||||
package smtpd
|
||||
package stringutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -99,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"},
|
||||
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>
|
||||
@@ -1,7 +1,7 @@
|
||||
Date: %DATE%
|
||||
To: %TO_ADDRESS%
|
||||
From: %FROM_ADDRESS%
|
||||
Subject: tutsplus responsive
|
||||
Subject: tutsplus responsive external CSS
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
|
||||
@@ -5,4 +5,6 @@ Subject: Swaks HTML
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
This is a test of <b>HTML</b> at the <i>top</i> level.
|
||||
<p style="font-family: 'Courier New', Courier, monospace;">
|
||||
This is a test of <b>HTML</b> at the <i>top</i> level.
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/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")"
|
||||
@@ -19,6 +20,7 @@ case "$1" in
|
||||
;;
|
||||
*)
|
||||
to="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -28,6 +30,10 @@ 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 mime-html.raw
|
||||
|
||||
@@ -35,7 +41,8 @@ swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
|
||||
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.raw
|
||||
@@ -46,5 +53,6 @@ swaks $* --data gmail.raw
|
||||
# Outlook test
|
||||
swaks $* --data outlook.raw
|
||||
|
||||
# Nonemime responsive HTML test
|
||||
# Non-mime responsive HTML test
|
||||
swaks $* --data nonmime-html-responsive.raw
|
||||
swaks $* --data nonmime-html-inlined.raw
|
||||
|
||||
@@ -29,16 +29,16 @@
|
||||
"test-infra"
|
||||
],
|
||||
"dependencies": {
|
||||
"jquery": "1.9.1 - 2"
|
||||
"jquery": "1.9.1 - 3"
|
||||
},
|
||||
"version": "3.3.6",
|
||||
"_release": "3.3.6",
|
||||
"version": "3.3.7",
|
||||
"_release": "3.3.7",
|
||||
"_resolution": {
|
||||
"type": "version",
|
||||
"tag": "v3.3.6",
|
||||
"commit": "81df608a40bf0629a1dc08e584849bb1e43e0b7a"
|
||||
"tag": "v3.3.7",
|
||||
"commit": "0b9c4a4007c44201dce9a6cc1a38407005c26c86"
|
||||
},
|
||||
"_source": "git://github.com/twbs/bootstrap.git",
|
||||
"_source": "https://github.com/twbs/bootstrap.git",
|
||||
"_target": "3.3",
|
||||
"_originalSource": "bootstrap"
|
||||
}
|
||||
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
|
||||
43
themes/bootstrap/public/bower_components/bootstrap/Gemfile.lock
vendored
Normal file
43
themes/bootstrap/public/bower_components/bootstrap/Gemfile.lock
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
addressable (2.4.0)
|
||||
colorator (0.1)
|
||||
ffi (1.9.14-x64-mingw32)
|
||||
jekyll (3.1.6)
|
||||
colorator (~> 0.1)
|
||||
jekyll-sass-converter (~> 1.0)
|
||||
jekyll-watch (~> 1.1)
|
||||
kramdown (~> 1.3)
|
||||
liquid (~> 3.0)
|
||||
mercenary (~> 0.3.3)
|
||||
rouge (~> 1.7)
|
||||
safe_yaml (~> 1.0)
|
||||
jekyll-sass-converter (1.4.0)
|
||||
sass (~> 3.4)
|
||||
jekyll-sitemap (0.11.0)
|
||||
addressable (~> 2.4.0)
|
||||
jekyll-watch (1.4.0)
|
||||
listen (~> 3.0, < 3.1)
|
||||
kramdown (1.11.1)
|
||||
liquid (3.0.6)
|
||||
listen (3.0.8)
|
||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
mercenary (0.3.6)
|
||||
rb-fsevent (0.9.7)
|
||||
rb-inotify (0.9.7)
|
||||
ffi (>= 0.5.0)
|
||||
rouge (1.11.1)
|
||||
safe_yaml (1.0.4)
|
||||
sass (3.4.22)
|
||||
|
||||
PLATFORMS
|
||||
x64-mingw32
|
||||
|
||||
DEPENDENCIES
|
||||
jekyll (~> 3.1.2)
|
||||
jekyll-sitemap (~> 0.11.0)
|
||||
|
||||
BUNDLED WITH
|
||||
1.12.5
|
||||
@@ -1,7 +1,7 @@
|
||||
/*!
|
||||
* Bootstrap's Gruntfile
|
||||
* http://getbootstrap.com
|
||||
* Copyright 2013-2015 Twitter, Inc.
|
||||
* Copyright 2013-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,6 @@ module.exports = function (grunt) {
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var npmShrinkwrap = require('npm-shrinkwrap');
|
||||
var generateGlyphiconsData = require('./grunt/bs-glyphicons-data-generator.js');
|
||||
var BsLessdocParser = require('./grunt/bs-lessdoc-parser.js');
|
||||
var getLessVarsData = function () {
|
||||
@@ -130,7 +129,7 @@ module.exports = function (grunt) {
|
||||
warnings: false
|
||||
},
|
||||
mangle: true,
|
||||
preserveComments: 'some'
|
||||
preserveComments: /^!|@preserve|@license|@cc_on/i
|
||||
},
|
||||
core: {
|
||||
src: '<%= concat.bootstrap.dest %>',
|
||||
@@ -232,6 +231,7 @@ module.exports = function (grunt) {
|
||||
compatibility: 'ie8',
|
||||
keepSpecialComments: '*',
|
||||
sourceMap: true,
|
||||
sourceMapInlineSources: true,
|
||||
advanced: false
|
||||
},
|
||||
minifyCore: {
|
||||
@@ -277,7 +277,7 @@ module.exports = function (grunt) {
|
||||
copy: {
|
||||
fonts: {
|
||||
expand: true,
|
||||
src: 'fonts/*',
|
||||
src: 'fonts/**',
|
||||
dest: 'dist/'
|
||||
},
|
||||
docs: {
|
||||
@@ -301,7 +301,9 @@ module.exports = function (grunt) {
|
||||
|
||||
jekyll: {
|
||||
options: {
|
||||
config: '_config.yml'
|
||||
bundleExec: true,
|
||||
config: '_config.yml',
|
||||
incremental: false
|
||||
},
|
||||
docs: {},
|
||||
github: {
|
||||
@@ -314,12 +316,27 @@ module.exports = function (grunt) {
|
||||
htmlmin: {
|
||||
dist: {
|
||||
options: {
|
||||
collapseBooleanAttributes: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
minifyCSS: true,
|
||||
decodeEntities: false,
|
||||
minifyCSS: {
|
||||
compatibility: 'ie8',
|
||||
keepSpecialComments: 0
|
||||
},
|
||||
minifyJS: true,
|
||||
minifyURLs: false,
|
||||
processConditionalComments: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeComments: true
|
||||
removeComments: true,
|
||||
removeOptionalAttributes: true,
|
||||
removeOptionalTags: true,
|
||||
removeRedundantAttributes: true,
|
||||
removeScriptTypeAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
removeTagWhitespace: false,
|
||||
sortAttributes: true,
|
||||
sortClassName: true
|
||||
},
|
||||
expand: true,
|
||||
cwd: '_gh_pages',
|
||||
@@ -331,17 +348,17 @@ module.exports = function (grunt) {
|
||||
}
|
||||
},
|
||||
|
||||
jade: {
|
||||
pug: {
|
||||
options: {
|
||||
pretty: true,
|
||||
data: getLessVarsData
|
||||
},
|
||||
customizerVars: {
|
||||
src: 'docs/_jade/customizer-variables.jade',
|
||||
src: 'docs/_pug/customizer-variables.pug',
|
||||
dest: 'docs/_includes/customizer-variables.html'
|
||||
},
|
||||
customizerNav: {
|
||||
src: 'docs/_jade/customizer-nav.jade',
|
||||
src: 'docs/_pug/customizer-nav.pug',
|
||||
dest: 'docs/_includes/nav/customize.html'
|
||||
}
|
||||
},
|
||||
@@ -350,7 +367,7 @@ module.exports = function (grunt) {
|
||||
options: {
|
||||
ignore: [
|
||||
'Attribute "autocomplete" not allowed on element "button" at this point.',
|
||||
'Attribute "autocomplete" is only allowed when the input type is "color", "date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search", "tel", "text", "time", "url", or "week".',
|
||||
'Attribute "autocomplete" is only allowed when the input type is "color", "date", "datetime", "datetime-local", "email", "hidden", "month", "number", "password", "range", "search", "tel", "text", "time", "url", or "week".',
|
||||
'Element "img" is missing required attribute "src".'
|
||||
]
|
||||
},
|
||||
@@ -372,25 +389,6 @@ module.exports = function (grunt) {
|
||||
}
|
||||
},
|
||||
|
||||
sed: {
|
||||
versionNumber: {
|
||||
pattern: (function () {
|
||||
var old = grunt.option('oldver');
|
||||
return old ? RegExp.quote(old) : old;
|
||||
})(),
|
||||
replacement: grunt.option('newver'),
|
||||
exclude: [
|
||||
'dist/fonts',
|
||||
'docs/assets',
|
||||
'fonts',
|
||||
'js/tests/vendor',
|
||||
'node_modules',
|
||||
'test-infra'
|
||||
],
|
||||
recursive: true
|
||||
}
|
||||
},
|
||||
|
||||
'saucelabs-qunit': {
|
||||
all: {
|
||||
options: {
|
||||
@@ -485,16 +483,11 @@ module.exports = function (grunt) {
|
||||
// Default task.
|
||||
grunt.registerTask('default', ['clean:dist', 'copy:fonts', 'test']);
|
||||
|
||||
// Version numbering task.
|
||||
// grunt change-version-number --oldver=A.B.C --newver=X.Y.Z
|
||||
// This can be overzealous, so its changes should always be manually reviewed!
|
||||
grunt.registerTask('change-version-number', 'sed');
|
||||
|
||||
grunt.registerTask('build-glyphicons-data', function () { generateGlyphiconsData.call(this, grunt); });
|
||||
|
||||
// task for building customizer
|
||||
grunt.registerTask('build-customizer', ['build-customizer-html', 'build-raw-files']);
|
||||
grunt.registerTask('build-customizer-html', 'jade');
|
||||
grunt.registerTask('build-customizer-html', 'pug');
|
||||
grunt.registerTask('build-raw-files', 'Add scripts/less files to customizer.', function () {
|
||||
var banner = grunt.template.process('<%= banner %>');
|
||||
generateRawFiles(grunt, banner);
|
||||
@@ -512,22 +505,7 @@ module.exports = function (grunt) {
|
||||
grunt.registerTask('docs-js', ['uglify:docsJs', 'uglify:customize']);
|
||||
grunt.registerTask('lint-docs-js', ['jshint:assets', 'jscs:assets']);
|
||||
grunt.registerTask('docs', ['docs-css', 'lint-docs-css', 'docs-js', 'lint-docs-js', 'clean:docs', 'copy:docs', 'build-glyphicons-data', 'build-customizer']);
|
||||
grunt.registerTask('docs-github', ['jekyll:github', 'htmlmin']);
|
||||
|
||||
grunt.registerTask('prep-release', ['dist', 'docs', 'jekyll:github', 'htmlmin', 'compress']);
|
||||
|
||||
// Task for updating the cached npm packages used by the Travis build (which are controlled by test-infra/npm-shrinkwrap.json).
|
||||
// This task should be run and the updated file should be committed whenever Bootstrap's dependencies change.
|
||||
grunt.registerTask('update-shrinkwrap', ['exec:npmUpdate', '_update-shrinkwrap']);
|
||||
grunt.registerTask('_update-shrinkwrap', function () {
|
||||
var done = this.async();
|
||||
npmShrinkwrap({ dev: true, dirname: __dirname }, function (err) {
|
||||
if (err) {
|
||||
grunt.fail.warn(err);
|
||||
}
|
||||
var dest = 'test-infra/npm-shrinkwrap.json';
|
||||
fs.renameSync('npm-shrinkwrap.json', dest);
|
||||
grunt.log.writeln('File ' + dest.cyan + ' updated.');
|
||||
done();
|
||||
});
|
||||
});
|
||||
grunt.registerTask('prep-release', ['dist', 'docs', 'docs-github', 'compress']);
|
||||
};
|
||||
|
||||
22
themes/bootstrap/public/bower_components/bootstrap/ISSUE_TEMPLATE.md
vendored
Normal file
22
themes/bootstrap/public/bower_components/bootstrap/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
Before opening an issue:
|
||||
|
||||
- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue)
|
||||
- [Validate](http://validator.w3.org/nu/) and [lint](https://github.com/twbs/bootlint#in-the-browser) any HTML to avoid common problems
|
||||
- Prepare a [reduced test case](https://css-tricks.com/reduced-test-cases/) for any bugs
|
||||
- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md)
|
||||
|
||||
When asking general "how to" questions:
|
||||
|
||||
- Please do not open an issue here
|
||||
- Instead, ask for help on [StackOverflow, IRC, or Slack](https://github.com/twbs/bootstrap/blob/master/README.md#community)
|
||||
|
||||
When reporting a bug, include:
|
||||
|
||||
- Operating system and version (Windows, Mac OS X, Android, iOS, Win10 Mobile)
|
||||
- Browser and version (Chrome, Firefox, Safari, IE, MS Edge, Opera 15+, Android Browser)
|
||||
- Reduced test cases and potential fixes using [JS Bin](https://jsbin.com)
|
||||
|
||||
When suggesting a feature, include:
|
||||
|
||||
- As much detail as possible for what we should add and why it's important to Bootstrap
|
||||
- Relevant links to prior art, screenshots, or live demos whenever possible
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2015 Twitter, Inc
|
||||
Copyright (c) 2011-2016 Twitter, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -29,10 +29,10 @@ To get started, check out <http://getbootstrap.com>!
|
||||
|
||||
Several quick start options are available:
|
||||
|
||||
* [Download the latest release](https://github.com/twbs/bootstrap/archive/v3.3.6.zip).
|
||||
* [Download the latest release](https://github.com/twbs/bootstrap/archive/v3.3.7.zip).
|
||||
* Clone the repo: `git clone https://github.com/twbs/bootstrap.git`.
|
||||
* Install with [Bower](http://bower.io): `bower install bootstrap`.
|
||||
* Install with [npm](https://www.npmjs.com): `npm install bootstrap`.
|
||||
* Install with [npm](https://www.npmjs.com): `npm install bootstrap@3`.
|
||||
* Install with [Meteor](https://www.meteor.com): `meteor add twbs:bootstrap`.
|
||||
* Install with [Composer](https://getcomposer.org): `composer require twbs/bootstrap`.
|
||||
|
||||
@@ -71,6 +71,8 @@ We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified
|
||||
|
||||
Have a bug or a feature request? Please first read the [issue guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md#using-the-issue-tracker) and search for existing and closed issues. If your problem or idea is not addressed yet, [please open a new issue](https://github.com/twbs/bootstrap/issues/new).
|
||||
|
||||
Note that **feature requests must target [Bootstrap v4](https://github.com/twbs/bootstrap/tree/v4-dev),** because Bootstrap v3 is now in maintenance mode and is closed off to new features. This is so that we can focus our efforts on Bootstrap v4.
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -78,10 +80,9 @@ Bootstrap's documentation, included in this repo in the root directory, is built
|
||||
|
||||
### Running documentation locally
|
||||
|
||||
1. If necessary, [install Jekyll](http://jekyllrb.com/docs/installation) (requires v3.0.x).
|
||||
1. If necessary, [install Jekyll](http://jekyllrb.com/docs/installation) and other Ruby dependencies with `bundle install`.
|
||||
**Note for Windows users:** Read [this unofficial guide](http://jekyll-windows.juthilo.com/) to get Jekyll up and running without problems.
|
||||
2. Install the Ruby-based syntax highlighter, [Rouge](https://github.com/jneen/rouge), with `gem install rouge`.
|
||||
3. From the root `/bootstrap` directory, run `jekyll serve` in the command line.
|
||||
2. From the root `/bootstrap` directory, run `bundle exec jekyll serve` in the command line.
|
||||
4. Open `http://localhost:9001` in your browser, and voilà.
|
||||
|
||||
Learn more about using Jekyll by reading its [documentation](http://jekyllrb.com/docs/home/).
|
||||
@@ -99,6 +100,8 @@ Please read through our [contributing guidelines](https://github.com/twbs/bootst
|
||||
|
||||
Moreover, if your pull request contains JavaScript patches or features, you must include [relevant unit tests](https://github.com/twbs/bootstrap/tree/master/js/tests). All HTML and CSS should conform to the [Code Guide](https://github.com/mdo/code-guide), maintained by [Mark Otto](https://github.com/mdo).
|
||||
|
||||
**Bootstrap v3 is now closed off to new features.** It has gone into maintenance mode so that we can focus our efforts on [Bootstrap v4](https://github.com/twbs/bootstrap/tree/v4-dev), the future of the framework. Pull requests which add new features (rather than fix bugs) should target [Bootstrap v4 (the `v4-dev` git branch)](https://github.com/twbs/bootstrap/tree/v4-dev) instead.
|
||||
|
||||
Editor preferences are available in the [editor config](https://github.com/twbs/bootstrap/blob/master/.editorconfig) for easy use in common text editors. Read more and download plugins at <http://editorconfig.org>.
|
||||
|
||||
|
||||
@@ -136,4 +139,4 @@ See [the Releases section of our GitHub project](https://github.com/twbs/bootstr
|
||||
|
||||
## Copyright and license
|
||||
|
||||
Code and documentation copyright 2011-2015 Twitter, Inc. Code released under [the MIT license](https://github.com/twbs/bootstrap/blob/master/LICENSE). Docs released under [Creative Commons](https://github.com/twbs/bootstrap/blob/master/docs/LICENSE).
|
||||
Code and documentation copyright 2011-2016 Twitter, Inc. Code released under [the MIT license](https://github.com/twbs/bootstrap/blob/master/LICENSE). Docs released under [Creative Commons](https://github.com/twbs/bootstrap/blob/master/docs/LICENSE).
|
||||
|
||||
@@ -29,6 +29,6 @@
|
||||
"test-infra"
|
||||
],
|
||||
"dependencies": {
|
||||
"jquery": "1.9.1 - 2"
|
||||
"jquery": "1.9.1 - 3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
* Bootstrap v3.3.6 (http://getbootstrap.com)
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Bootstrap v3.3.7 (http://getbootstrap.com)
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
.btn-default,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
* Bootstrap v3.3.6 (http://getbootstrap.com)
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Bootstrap v3.3.7 (http://getbootstrap.com)
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
|
||||
@@ -1106,7 +1106,6 @@ a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:focus {
|
||||
outline: thin dotted;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -2537,7 +2536,6 @@ select[size] {
|
||||
input[type="file"]:focus,
|
||||
input[type="radio"]:focus,
|
||||
input[type="checkbox"]:focus {
|
||||
outline: thin dotted;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -3029,7 +3027,6 @@ select[multiple].input-lg {
|
||||
.btn.focus,
|
||||
.btn:active.focus,
|
||||
.btn.active.focus {
|
||||
outline: thin dotted;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
* Bootstrap v3.3.6 (http://getbootstrap.com)
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Bootstrap v3.3.7 (http://getbootstrap.com)
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under the MIT license
|
||||
*/
|
||||
|
||||
@@ -11,16 +11,16 @@ if (typeof jQuery === 'undefined') {
|
||||
+function ($) {
|
||||
'use strict';
|
||||
var version = $.fn.jquery.split(' ')[0].split('.')
|
||||
if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) {
|
||||
throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3')
|
||||
if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) {
|
||||
throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4')
|
||||
}
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: transition.js v3.3.6
|
||||
* Bootstrap: transition.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#transitions
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: alert.js v3.3.6
|
||||
* Bootstrap: alert.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#alerts
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -96,7 +96,7 @@ if (typeof jQuery === 'undefined') {
|
||||
$(el).on('click', dismiss, this.close)
|
||||
}
|
||||
|
||||
Alert.VERSION = '3.3.6'
|
||||
Alert.VERSION = '3.3.7'
|
||||
|
||||
Alert.TRANSITION_DURATION = 150
|
||||
|
||||
@@ -109,7 +109,7 @@ if (typeof jQuery === 'undefined') {
|
||||
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
|
||||
}
|
||||
|
||||
var $parent = $(selector)
|
||||
var $parent = $(selector === '#' ? [] : selector)
|
||||
|
||||
if (e) e.preventDefault()
|
||||
|
||||
@@ -172,10 +172,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: button.js v3.3.6
|
||||
* Bootstrap: button.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#buttons
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -192,7 +192,7 @@ if (typeof jQuery === 'undefined') {
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
Button.VERSION = '3.3.6'
|
||||
Button.VERSION = '3.3.7'
|
||||
|
||||
Button.DEFAULTS = {
|
||||
loadingText: 'loading...'
|
||||
@@ -214,10 +214,10 @@ if (typeof jQuery === 'undefined') {
|
||||
|
||||
if (state == 'loadingText') {
|
||||
this.isLoading = true
|
||||
$el.addClass(d).attr(d, d)
|
||||
$el.addClass(d).attr(d, d).prop(d, true)
|
||||
} else if (this.isLoading) {
|
||||
this.isLoading = false
|
||||
$el.removeClass(d).removeAttr(d)
|
||||
$el.removeClass(d).removeAttr(d).prop(d, false)
|
||||
}
|
||||
}, this), 0)
|
||||
}
|
||||
@@ -281,10 +281,15 @@ if (typeof jQuery === 'undefined') {
|
||||
|
||||
$(document)
|
||||
.on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
|
||||
var $btn = $(e.target)
|
||||
if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn')
|
||||
var $btn = $(e.target).closest('.btn')
|
||||
Plugin.call($btn, 'toggle')
|
||||
if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault()
|
||||
if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) {
|
||||
// Prevent double click on radios, and the double selections (so cancellation) on checkboxes
|
||||
e.preventDefault()
|
||||
// The target component still receive the focus
|
||||
if ($btn.is('input,button')) $btn.trigger('focus')
|
||||
else $btn.find('input:visible,button:visible').first().trigger('focus')
|
||||
}
|
||||
})
|
||||
.on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) {
|
||||
$(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))
|
||||
@@ -293,10 +298,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: carousel.js v3.3.6
|
||||
* Bootstrap: carousel.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#carousel
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -324,7 +329,7 @@ if (typeof jQuery === 'undefined') {
|
||||
.on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
|
||||
}
|
||||
|
||||
Carousel.VERSION = '3.3.6'
|
||||
Carousel.VERSION = '3.3.7'
|
||||
|
||||
Carousel.TRANSITION_DURATION = 600
|
||||
|
||||
@@ -531,13 +536,14 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: collapse.js v3.3.6
|
||||
* Bootstrap: collapse.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#collapse
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
/* jshint latedef: false */
|
||||
|
||||
+function ($) {
|
||||
'use strict';
|
||||
@@ -561,7 +567,7 @@ if (typeof jQuery === 'undefined') {
|
||||
if (this.options.toggle) this.toggle()
|
||||
}
|
||||
|
||||
Collapse.VERSION = '3.3.6'
|
||||
Collapse.VERSION = '3.3.7'
|
||||
|
||||
Collapse.TRANSITION_DURATION = 350
|
||||
|
||||
@@ -743,10 +749,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: dropdown.js v3.3.6
|
||||
* Bootstrap: dropdown.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#dropdowns
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -763,7 +769,7 @@ if (typeof jQuery === 'undefined') {
|
||||
$(element).on('click.bs.dropdown', this.toggle)
|
||||
}
|
||||
|
||||
Dropdown.VERSION = '3.3.6'
|
||||
Dropdown.VERSION = '3.3.7'
|
||||
|
||||
function getParent($this) {
|
||||
var selector = $this.attr('data-target')
|
||||
@@ -909,10 +915,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: modal.js v3.3.6
|
||||
* Bootstrap: modal.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#modals
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -943,7 +949,7 @@ if (typeof jQuery === 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
Modal.VERSION = '3.3.6'
|
||||
Modal.VERSION = '3.3.7'
|
||||
|
||||
Modal.TRANSITION_DURATION = 300
|
||||
Modal.BACKDROP_TRANSITION_DURATION = 150
|
||||
@@ -1050,7 +1056,9 @@ if (typeof jQuery === 'undefined') {
|
||||
$(document)
|
||||
.off('focusin.bs.modal') // guard against infinite focus loop
|
||||
.on('focusin.bs.modal', $.proxy(function (e) {
|
||||
if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
|
||||
if (document !== e.target &&
|
||||
this.$element[0] !== e.target &&
|
||||
!this.$element.has(e.target).length) {
|
||||
this.$element.trigger('focus')
|
||||
}
|
||||
}, this))
|
||||
@@ -1247,11 +1255,11 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: tooltip.js v3.3.6
|
||||
* Bootstrap: tooltip.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#tooltip
|
||||
* Inspired by the original jQuery.tipsy by Jason Frame
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -1274,7 +1282,7 @@ if (typeof jQuery === 'undefined') {
|
||||
this.init('tooltip', element, options)
|
||||
}
|
||||
|
||||
Tooltip.VERSION = '3.3.6'
|
||||
Tooltip.VERSION = '3.3.7'
|
||||
|
||||
Tooltip.TRANSITION_DURATION = 150
|
||||
|
||||
@@ -1565,9 +1573,11 @@ if (typeof jQuery === 'undefined') {
|
||||
|
||||
function complete() {
|
||||
if (that.hoverState != 'in') $tip.detach()
|
||||
that.$element
|
||||
.removeAttr('aria-describedby')
|
||||
.trigger('hidden.bs.' + that.type)
|
||||
if (that.$element) { // TODO: Check whether guarding this code with this `if` is really necessary.
|
||||
that.$element
|
||||
.removeAttr('aria-describedby')
|
||||
.trigger('hidden.bs.' + that.type)
|
||||
}
|
||||
callback && callback()
|
||||
}
|
||||
|
||||
@@ -1610,7 +1620,10 @@ if (typeof jQuery === 'undefined') {
|
||||
// width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093
|
||||
elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top })
|
||||
}
|
||||
var elOffset = isBody ? { top: 0, left: 0 } : $element.offset()
|
||||
var isSvg = window.SVGElement && el instanceof window.SVGElement
|
||||
// Avoid using $.offset() on SVGs since it gives incorrect results in jQuery 3.
|
||||
// See https://github.com/twbs/bootstrap/issues/20280
|
||||
var elOffset = isBody ? { top: 0, left: 0 } : (isSvg ? null : $element.offset())
|
||||
var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() }
|
||||
var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null
|
||||
|
||||
@@ -1726,6 +1739,7 @@ if (typeof jQuery === 'undefined') {
|
||||
that.$tip = null
|
||||
that.$arrow = null
|
||||
that.$viewport = null
|
||||
that.$element = null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1762,10 +1776,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: popover.js v3.3.6
|
||||
* Bootstrap: popover.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#popovers
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -1782,7 +1796,7 @@ if (typeof jQuery === 'undefined') {
|
||||
|
||||
if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js')
|
||||
|
||||
Popover.VERSION = '3.3.6'
|
||||
Popover.VERSION = '3.3.7'
|
||||
|
||||
Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
|
||||
placement: 'right',
|
||||
@@ -1871,10 +1885,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: scrollspy.js v3.3.6
|
||||
* Bootstrap: scrollspy.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#scrollspy
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -1900,7 +1914,7 @@ if (typeof jQuery === 'undefined') {
|
||||
this.process()
|
||||
}
|
||||
|
||||
ScrollSpy.VERSION = '3.3.6'
|
||||
ScrollSpy.VERSION = '3.3.7'
|
||||
|
||||
ScrollSpy.DEFAULTS = {
|
||||
offset: 10
|
||||
@@ -2044,10 +2058,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: tab.js v3.3.6
|
||||
* Bootstrap: tab.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#tabs
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -2064,7 +2078,7 @@ if (typeof jQuery === 'undefined') {
|
||||
// jscs:enable requireDollarBeforejQueryAssignment
|
||||
}
|
||||
|
||||
Tab.VERSION = '3.3.6'
|
||||
Tab.VERSION = '3.3.7'
|
||||
|
||||
Tab.TRANSITION_DURATION = 150
|
||||
|
||||
@@ -2200,10 +2214,10 @@ if (typeof jQuery === 'undefined') {
|
||||
}(jQuery);
|
||||
|
||||
/* ========================================================================
|
||||
* Bootstrap: affix.js v3.3.6
|
||||
* Bootstrap: affix.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#affix
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -2229,7 +2243,7 @@ if (typeof jQuery === 'undefined') {
|
||||
this.checkPosition()
|
||||
}
|
||||
|
||||
Affix.VERSION = '3.3.6'
|
||||
Affix.VERSION = '3.3.7'
|
||||
|
||||
Affix.RESET = 'affix affix-top affix-bottom'
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
109
themes/bootstrap/public/bower_components/bootstrap/grunt/change-version.js
vendored
Executable file
109
themes/bootstrap/public/bower_components/bootstrap/grunt/change-version.js
vendored
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/* globals Set */
|
||||
/*!
|
||||
* Script to update version number references in the project.
|
||||
* Copyright 2015 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var sh = require('shelljs');
|
||||
sh.config.fatal = true;
|
||||
var sed = sh.sed;
|
||||
|
||||
// Blame TC39... https://github.com/benjamingr/RegExp.escape/issues/37
|
||||
RegExp.quote = function (string) {
|
||||
return string.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
};
|
||||
RegExp.quoteReplacement = function (string) {
|
||||
return string.replace(/[$]/g, '$$');
|
||||
};
|
||||
|
||||
var DRY_RUN = false;
|
||||
|
||||
function walkAsync(directory, excludedDirectories, fileCallback, errback) {
|
||||
if (excludedDirectories.has(path.parse(directory).base)) {
|
||||
return;
|
||||
}
|
||||
fs.readdir(directory, function (err, names) {
|
||||
if (err) {
|
||||
errback(err);
|
||||
return;
|
||||
}
|
||||
names.forEach(function (name) {
|
||||
var filepath = path.join(directory, name);
|
||||
fs.lstat(filepath, function (err, stats) {
|
||||
if (err) {
|
||||
process.nextTick(errback, err);
|
||||
return;
|
||||
}
|
||||
if (stats.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
else if (stats.isDirectory()) {
|
||||
process.nextTick(walkAsync, filepath, excludedDirectories, fileCallback, errback);
|
||||
}
|
||||
else if (stats.isFile()) {
|
||||
process.nextTick(fileCallback, filepath);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function replaceRecursively(directory, excludedDirectories, allowedExtensions, original, replacement) {
|
||||
original = new RegExp(RegExp.quote(original), 'g');
|
||||
replacement = RegExp.quoteReplacement(replacement);
|
||||
var updateFile = !DRY_RUN ? function (filepath) {
|
||||
if (allowedExtensions.has(path.parse(filepath).ext)) {
|
||||
sed('-i', original, replacement, filepath);
|
||||
}
|
||||
} : function (filepath) {
|
||||
if (allowedExtensions.has(path.parse(filepath).ext)) {
|
||||
console.log('FILE: ' + filepath);
|
||||
}
|
||||
else {
|
||||
console.log('EXCLUDED:' + filepath);
|
||||
}
|
||||
};
|
||||
walkAsync(directory, excludedDirectories, updateFile, function (err) {
|
||||
console.error('ERROR while traversing directory!:');
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
if (args.length !== 2) {
|
||||
console.error('USAGE: change-version old_version new_version');
|
||||
console.error('Got arguments:', args);
|
||||
process.exit(1);
|
||||
}
|
||||
var oldVersion = args[0];
|
||||
var newVersion = args[1];
|
||||
var EXCLUDED_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'vendor'
|
||||
]);
|
||||
var INCLUDED_EXTENSIONS = new Set([
|
||||
// This extension whitelist is how we avoid modifying binary files
|
||||
'',
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.less',
|
||||
'.md',
|
||||
'.nuspec',
|
||||
'.ps1',
|
||||
'.scss',
|
||||
'.txt',
|
||||
'.yml'
|
||||
]);
|
||||
replaceRecursively('.', EXCLUDED_DIRS, INCLUDED_EXTENSIONS, oldVersion, newVersion);
|
||||
}
|
||||
|
||||
main(process.argv.slice(2));
|
||||
@@ -13,7 +13,7 @@
|
||||
"docsJs": [
|
||||
"../assets/js/vendor/holder.min.js",
|
||||
"../assets/js/vendor/ZeroClipboard.min.js",
|
||||
"../assets/js/vendor/anchor.js",
|
||||
"../assets/js/vendor/anchor.min.js",
|
||||
"../assets/js/src/application.js"
|
||||
]
|
||||
},
|
||||
@@ -37,8 +37,8 @@
|
||||
"+function ($) {",
|
||||
" 'use strict';",
|
||||
" var version = $.fn.jquery.split(' ')[0].split('.')",
|
||||
" if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) {",
|
||||
" throw new Error('Bootstrap\\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3')",
|
||||
" if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) {",
|
||||
" throw new Error('Bootstrap\\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4')",
|
||||
" }",
|
||||
"}(jQuery);\n\n"
|
||||
]
|
||||
|
||||
2679
themes/bootstrap/public/bower_components/bootstrap/grunt/npm-shrinkwrap.json
generated
vendored
Normal file
2679
themes/bootstrap/public/bower_components/bootstrap/grunt/npm-shrinkwrap.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@
|
||||
{
|
||||
browserName: "iphone",
|
||||
platform: "OS X 10.10",
|
||||
version: "8.2"
|
||||
version: "9.2"
|
||||
},
|
||||
|
||||
# iOS Chrome not currently supported by Sauce Labs
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* Bootstrap: affix.js v3.3.6
|
||||
* Bootstrap: affix.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#affix
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
this.checkPosition()
|
||||
}
|
||||
|
||||
Affix.VERSION = '3.3.6'
|
||||
Affix.VERSION = '3.3.7'
|
||||
|
||||
Affix.RESET = 'affix affix-top affix-bottom'
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* Bootstrap: alert.js v3.3.6
|
||||
* Bootstrap: alert.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#alerts
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
$(el).on('click', dismiss, this.close)
|
||||
}
|
||||
|
||||
Alert.VERSION = '3.3.6'
|
||||
Alert.VERSION = '3.3.7'
|
||||
|
||||
Alert.TRANSITION_DURATION = 150
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
|
||||
}
|
||||
|
||||
var $parent = $(selector)
|
||||
var $parent = $(selector === '#' ? [] : selector)
|
||||
|
||||
if (e) e.preventDefault()
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* Bootstrap: button.js v3.3.6
|
||||
* Bootstrap: button.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#buttons
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
Button.VERSION = '3.3.6'
|
||||
Button.VERSION = '3.3.7'
|
||||
|
||||
Button.DEFAULTS = {
|
||||
loadingText: 'loading...'
|
||||
@@ -41,10 +41,10 @@
|
||||
|
||||
if (state == 'loadingText') {
|
||||
this.isLoading = true
|
||||
$el.addClass(d).attr(d, d)
|
||||
$el.addClass(d).attr(d, d).prop(d, true)
|
||||
} else if (this.isLoading) {
|
||||
this.isLoading = false
|
||||
$el.removeClass(d).removeAttr(d)
|
||||
$el.removeClass(d).removeAttr(d).prop(d, false)
|
||||
}
|
||||
}, this), 0)
|
||||
}
|
||||
@@ -108,10 +108,15 @@
|
||||
|
||||
$(document)
|
||||
.on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
|
||||
var $btn = $(e.target)
|
||||
if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn')
|
||||
var $btn = $(e.target).closest('.btn')
|
||||
Plugin.call($btn, 'toggle')
|
||||
if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault()
|
||||
if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) {
|
||||
// Prevent double click on radios, and the double selections (so cancellation) on checkboxes
|
||||
e.preventDefault()
|
||||
// The target component still receive the focus
|
||||
if ($btn.is('input,button')) $btn.trigger('focus')
|
||||
else $btn.find('input:visible,button:visible').first().trigger('focus')
|
||||
}
|
||||
})
|
||||
.on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) {
|
||||
$(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* Bootstrap: carousel.js v3.3.6
|
||||
* Bootstrap: carousel.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#carousel
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
.on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
|
||||
}
|
||||
|
||||
Carousel.VERSION = '3.3.6'
|
||||
Carousel.VERSION = '3.3.7'
|
||||
|
||||
Carousel.TRANSITION_DURATION = 600
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* ========================================================================
|
||||
* Bootstrap: collapse.js v3.3.6
|
||||
* Bootstrap: collapse.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#collapse
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
/* jshint latedef: false */
|
||||
|
||||
+function ($) {
|
||||
'use strict';
|
||||
@@ -29,7 +30,7 @@
|
||||
if (this.options.toggle) this.toggle()
|
||||
}
|
||||
|
||||
Collapse.VERSION = '3.3.6'
|
||||
Collapse.VERSION = '3.3.7'
|
||||
|
||||
Collapse.TRANSITION_DURATION = 350
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* Bootstrap: dropdown.js v3.3.6
|
||||
* Bootstrap: dropdown.js v3.3.7
|
||||
* http://getbootstrap.com/javascript/#dropdowns
|
||||
* ========================================================================
|
||||
* Copyright 2011-2015 Twitter, Inc.
|
||||
* Copyright 2011-2016 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
$(element).on('click.bs.dropdown', this.toggle)
|
||||
}
|
||||
|
||||
Dropdown.VERSION = '3.3.6'
|
||||
Dropdown.VERSION = '3.3.7'
|
||||
|
||||
function getParent($this) {
|
||||
var selector = $this.attr('data-target')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user