mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 10:07:02 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3062b70ea0 | ||
|
|
01d51302c4 | ||
|
|
c750dcff81 | ||
|
|
de75b778c0 | ||
|
|
b28e1d86d8 | ||
|
|
f4fadd7e44 | ||
|
|
28b40eb94d | ||
|
|
0f67e51e56 | ||
|
|
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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -25,10 +25,13 @@ _testmain.go
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# our binary
|
||||
# our binaries
|
||||
/inbucket
|
||||
/inbucket.exe
|
||||
/dist/**
|
||||
/target/**
|
||||
/cmd/client/client
|
||||
/cmd/client/client.exe
|
||||
|
||||
# local goxc config
|
||||
.goxc.local.json
|
||||
|
||||
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
|
||||
@@ -6,7 +6,7 @@
|
||||
"Arch": "amd64",
|
||||
"Os": "darwin freebsd linux windows",
|
||||
"ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes",
|
||||
"PackageVersion": "1.1.0",
|
||||
"PackageVersion": "1.2.0",
|
||||
"ConfigVersion": "0.9",
|
||||
"BuildSettings": {
|
||||
"LdFlagsXVars": {
|
||||
|
||||
16
.travis.yml
16
.travis.yml
@@ -1,9 +1,21 @@
|
||||
language: go
|
||||
sudo: false
|
||||
|
||||
env:
|
||||
- DEPLOY_WITH_MAJOR="1.9"
|
||||
|
||||
before_script:
|
||||
- go vet ./...
|
||||
|
||||
go:
|
||||
- 1.6.3
|
||||
- 1.7
|
||||
- 1.8.x
|
||||
- 1.9.x
|
||||
|
||||
script: go test -race -v ./...
|
||||
|
||||
deploy:
|
||||
provider: script
|
||||
script: etc/travis-deploy.sh
|
||||
on:
|
||||
tags: true
|
||||
branch: master
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -4,8 +4,58 @@ Change Log
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
[1.2.0] - 2017-12-27
|
||||
--------------------
|
||||
|
||||
### Changed
|
||||
|
||||
- No significant code changes from rc2
|
||||
|
||||
[1.2.0-rc2] - 2017-12-15
|
||||
------------------------
|
||||
|
||||
### 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
|
||||
|
||||
[1.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
|
||||
|
||||
[1.1.0] - 2016-09-03
|
||||
------------
|
||||
--------------------
|
||||
|
||||
### Added
|
||||
- Homebrew inbucket.conf and formula (see README)
|
||||
@@ -49,6 +99,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
specific message.
|
||||
|
||||
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
|
||||
[1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
|
||||
[1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
|
||||
[1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
|
||||
[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
|
||||
@@ -63,7 +116,7 @@ Release Checklist
|
||||
- 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`
|
||||
3. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=rc1`
|
||||
4. Run: `goxc interpolate-source` to update VERSION var
|
||||
5. Run tests
|
||||
6. Test cross-compile: `goxc`
|
||||
|
||||
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)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
@@ -14,6 +16,8 @@ type Context struct {
|
||||
Vars map[string]string
|
||||
Session *sessions.Session
|
||||
DataStore smtpd.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,17 +2,19 @@
|
||||
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/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
@@ -23,6 +25,9 @@ var (
|
||||
// DataStore is where all the mailboxes and messages live
|
||||
DataStore smtpd.DataStore
|
||||
|
||||
// msgHub holds a reference to the message pub/sub system
|
||||
msgHub *msghub.Hub
|
||||
|
||||
// Router is shared between httpd, webui and rest packages. It sends
|
||||
// incoming requests to the correct handler function
|
||||
Router = mux.NewRouter()
|
||||
@@ -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 smtpd.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() {
|
||||
|
||||
70
inbucket.go
70
inbucket.go
@@ -2,17 +2,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"expvar"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"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 +23,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 = "1.2.0"
|
||||
|
||||
// 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 +42,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 +70,9 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Root context
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
|
||||
// Load & Parse config
|
||||
if flag.NArg() != 1 {
|
||||
flag.Usage()
|
||||
@@ -68,8 +89,7 @@ func main() {
|
||||
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 +111,26 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create message hub
|
||||
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
|
||||
|
||||
// Grab our datastore
|
||||
ds := smtpd.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()
|
||||
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:
|
||||
@@ -128,6 +151,7 @@ signalLoop:
|
||||
close(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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package pop3d
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
@@ -18,7 +19,6 @@ type Server struct {
|
||||
dataStore smtpd.DataStore
|
||||
listener net.Listener
|
||||
globalShutdown chan bool
|
||||
localShutdown chan bool
|
||||
waitgroup *sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -35,20 +35,17 @@ func New(shutdownChan chan bool) *Server {
|
||||
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() {
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
cfg := config.GetPOP3Config()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
|
||||
cfg.IP4address, cfg.IP4port))
|
||||
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 +54,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 +74,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 +95,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 +123,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,42 +4,18 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/rest/model"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -59,12 +35,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(),
|
||||
@@ -104,19 +81,35 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,7 +169,7 @@ 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"]
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
mailboxKey = "mailbox"
|
||||
idKey = "id"
|
||||
fromKey = "from"
|
||||
toKey = "to"
|
||||
subjectKey = "subject"
|
||||
dateKey = "date"
|
||||
sizeKey = "size"
|
||||
@@ -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,6 +103,7 @@ 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)),
|
||||
}
|
||||
|
||||
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/smtpd"
|
||||
)
|
||||
|
||||
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 := smtpd.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
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
@@ -50,6 +50,11 @@ func (m *MockMailbox) NewMessage() (smtpd.Message, error) {
|
||||
return args.Get(0).(smtpd.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) Name() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) String() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
@@ -70,6 +75,11 @@ func (m *MockMessage) From() string {
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockMessage) To() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
func (m *MockMessage) Date() time.Time {
|
||||
args := m.Called()
|
||||
return args.Get(0).(time.Time)
|
||||
@@ -85,9 +95,9 @@ func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
|
||||
return args.Get(0).(*mail.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMessage) ReadBody() (body *enmime.MIMEBody, err error) {
|
||||
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)
|
||||
}
|
||||
|
||||
func (m *MockMessage) ReadRaw() (raw *string, err error) {
|
||||
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/httpd"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
"github.com/jhillyerd/inbucket/smtpd"
|
||||
)
|
||||
|
||||
type InputMessageData struct {
|
||||
Mailbox, ID, From, Subject string
|
||||
To []string
|
||||
Date time.Time
|
||||
Size int
|
||||
Header mail.Header
|
||||
@@ -27,6 +29,7 @@ func (d *InputMessageData) MockMessage() *MockMessage {
|
||||
msg := &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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/mail"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,6 +29,7 @@ type Mailbox interface {
|
||||
GetMessage(id string) (Message, error)
|
||||
Purge() error
|
||||
NewMessage() (Message, error)
|
||||
Name() string
|
||||
String() string
|
||||
}
|
||||
|
||||
@@ -36,11 +37,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
|
||||
|
||||
269
smtpd/filemsg.go
Normal file
269
smtpd/filemsg.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package smtpd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/enmime"
|
||||
"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() (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
|
||||
}
|
||||
|
||||
// From 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 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())
|
||||
}
|
||||
@@ -6,13 +6,11 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -21,11 +19,14 @@ import (
|
||||
const indexFileName = "index.gob"
|
||||
|
||||
var (
|
||||
// indexLock is locked while reading/writing an index file
|
||||
// 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
|
||||
// NOTE: This is a bottleneck because it's a single lock even if we have a
|
||||
// million index files
|
||||
indexLock = new(sync.RWMutex)
|
||||
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
|
||||
@@ -148,6 +149,10 @@ type FileMailbox struct {
|
||||
messages []*FileMessage
|
||||
}
|
||||
|
||||
func (mb *FileMailbox) Name() string {
|
||||
return mb.name
|
||||
}
|
||||
|
||||
func (mb *FileMailbox) String() string {
|
||||
return mb.name + "[" + mb.dirName + "]"
|
||||
}
|
||||
@@ -176,9 +181,13 @@ func (mb *FileMailbox) GetMessage(id string) (Message, error) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range mb.messages {
|
||||
if m.Fid == id {
|
||||
return m, nil
|
||||
if id == "latest" && len(mb.messages) != 0 {
|
||||
return mb.messages[len(mb.messages)-1], nil
|
||||
} else {
|
||||
for _, m := range mb.messages {
|
||||
if m.Fid == id {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +205,8 @@ func (mb *FileMailbox) readIndex() error {
|
||||
// Clear message slice, open index
|
||||
mb.messages = mb.messages[:0]
|
||||
// Lock for reading
|
||||
indexLock.RLock()
|
||||
defer indexLock.RUnlock()
|
||||
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
|
||||
@@ -234,22 +243,11 @@ func (mb *FileMailbox) readIndex() error {
|
||||
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()
|
||||
indexMx.Lock()
|
||||
defer indexMx.Unlock()
|
||||
if len(mb.messages) > 0 {
|
||||
// Ensure mailbox directory exists
|
||||
if err := mb.createDir(); err != nil {
|
||||
@@ -260,269 +258,85 @@ func (mb *FileMailbox) writeIndex() error {
|
||||
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 {
|
||||
_ = 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 os.RemoveAll(mb.path)
|
||||
return mb.removeDir()
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Fetch headers
|
||||
body, err := m.ReadBody()
|
||||
if err != 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// remove parents if empty
|
||||
dir := filepath.Dir(mb.path)
|
||||
if removeDirIfEmpty(dir) {
|
||||
removeDirIfEmpty(filepath.Dir(dir))
|
||||
}
|
||||
|
||||
// Made it this far without errors, add it to the index
|
||||
m.mailbox.messages = append(m.mailbox.messages, m)
|
||||
return m.mailbox.writeIndex()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
if err := m.mailbox.writeIndex(); err != nil {
|
||||
return err
|
||||
files, err := f.Readdirnames(0)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
if len(files) > 0 {
|
||||
// Dir not empty
|
||||
return false
|
||||
}
|
||||
|
||||
// There are still messages in the index
|
||||
log.Tracef("Deleting %v", m.rawPath())
|
||||
return os.Remove(m.rawPath())
|
||||
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
|
||||
|
||||
@@ -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.Error(t, err)
|
||||
fmt.Println(msg)
|
||||
|
||||
// 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
|
||||
msg, 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")
|
||||
|
||||
137
smtpd/handler.go
137
smtpd/handler.go
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
// State tracks the current mode of our SMTP state machine
|
||||
@@ -67,6 +68,12 @@ var commands = map[string]bool{
|
||||
"TURN": true,
|
||||
}
|
||||
|
||||
// recipientDetails for message delivery
|
||||
type recipientDetails struct {
|
||||
address, localPart, domainPart string
|
||||
mailbox Mailbox
|
||||
}
|
||||
|
||||
// Session holds the state of an SMTP session
|
||||
type Session struct {
|
||||
server *Server
|
||||
@@ -341,14 +348,10 @@ 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)
|
||||
@@ -367,35 +370,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 +393,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 +421,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 +432,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) {
|
||||
@@ -495,28 +515,15 @@ func (ss *Session) readByteLine(buf *bytes.Buffer) error {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
line, err := ss.reader.ReadBytes('\r')
|
||||
line, err := ss.reader.ReadBytes('\n')
|
||||
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
|
||||
return nil
|
||||
}
|
||||
// Should be unreachable
|
||||
}
|
||||
|
||||
// Reads a line of input
|
||||
|
||||
@@ -2,16 +2,19 @@ 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/msghub"
|
||||
)
|
||||
|
||||
type scriptStep struct {
|
||||
@@ -26,8 +29,8 @@ func TestGreetState(t *testing.T) {
|
||||
mb1 := &MockMailbox{}
|
||||
mds.On("MailboxFor").Return(mb1, nil)
|
||||
|
||||
server, logbuf := setupSMTPServer(mds)
|
||||
defer teardownSMTPServer(server)
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
|
||||
var script []scriptStep
|
||||
|
||||
@@ -87,8 +90,8 @@ func TestReadyState(t *testing.T) {
|
||||
mb1 := &MockMailbox{}
|
||||
mds.On("MailboxFor").Return(mb1, nil)
|
||||
|
||||
server, logbuf := setupSMTPServer(mds)
|
||||
defer teardownSMTPServer(server)
|
||||
server, logbuf, teardown := setupSMTPServer(mds)
|
||||
defer teardown()
|
||||
|
||||
var script []scriptStep
|
||||
|
||||
@@ -153,10 +156,17 @@ func TestMailState(t *testing.T) {
|
||||
msg1 := &MockMessage{}
|
||||
mds.On("MailboxFor").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
|
||||
|
||||
@@ -263,10 +273,17 @@ func TestDataState(t *testing.T) {
|
||||
msg1 := &MockMessage{}
|
||||
mds.On("MailboxFor").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 +376,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) (s *Server, buf *bytes.Buffer, teardown func()) {
|
||||
// Test Server Config
|
||||
cfg := config.SMTPConfig{
|
||||
IP4address: net.IPv4(127, 0, 0, 1),
|
||||
@@ -373,12 +390,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 +416,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"
|
||||
@@ -11,27 +12,28 @@ import (
|
||||
|
||||
"github.com/jhillyerd/inbucket/config"
|
||||
"github.com/jhillyerd/inbucket/log"
|
||||
"github.com/jhillyerd/inbucket/msghub"
|
||||
)
|
||||
|
||||
// Server holds the configuration and state of our SMTP server
|
||||
type Server struct {
|
||||
// Configuration
|
||||
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 // Mailbox/message store
|
||||
globalShutdown chan bool // Shuts down Inbucket
|
||||
msgHub *msghub.Hub // Pub/sub for message info
|
||||
retentionScanner *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 +58,33 @@ 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,
|
||||
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),
|
||||
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: NewRetentionScanner(ds, globalShutdown),
|
||||
waitgroup: new(sync.WaitGroup),
|
||||
}
|
||||
}
|
||||
|
||||
// Start the listener and handle incoming connections
|
||||
func (s *Server) Start() {
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
cfg := config.GetSMTPConfig()
|
||||
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
|
||||
cfg.IP4address, cfg.IP4port))
|
||||
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 +93,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,14 +104,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:
|
||||
case <-ctx.Done():
|
||||
log.Tracef("SMTP shutdown requested, connections will be drained")
|
||||
}
|
||||
|
||||
@@ -119,7 +122,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 +144,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
|
||||
}
|
||||
@@ -170,14 +173,10 @@ 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()
|
||||
s.retentionScanner.Join()
|
||||
}
|
||||
|
||||
// When the provided Ticker ticks, we update our metrics history
|
||||
|
||||
@@ -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,100 @@ 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)
|
||||
}
|
||||
|
||||
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):
|
||||
}
|
||||
}
|
||||
|
||||
// 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,28 +136,26 @@ 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 {
|
||||
// Join does not retun until the retention scanner has shut down
|
||||
func (rs *RetentionScanner) Join() {
|
||||
if rs.retentionShutdown != nil {
|
||||
select {
|
||||
case _ = <-retentionShutdown:
|
||||
case <-rs.retentionShutdown:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,27 +163,15 @@ func RetentionJoin() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jhillyerd/go.enmime"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,12 @@ func TestDoRetentionScan(t *testing.T) {
|
||||
mb3.On("GetMessages").Return([]Message{new3}, nil)
|
||||
|
||||
// Test 4 hour retention
|
||||
if err := doRetentionScan(mds, 4*time.Hour-time.Minute, 0); err != nil {
|
||||
rs := &RetentionScanner{
|
||||
ds: mds,
|
||||
retentionPeriod: 4*time.Hour - time.Minute,
|
||||
retentionSleep: 0,
|
||||
}
|
||||
if err := rs.doScan(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
@@ -106,6 +111,11 @@ func (m *MockMailbox) NewMessage() (Message, error) {
|
||||
return args.Get(0).(Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) Name() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockMailbox) String() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
@@ -126,6 +136,11 @@ func (m *MockMessage) From() string {
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockMessage) To() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
func (m *MockMessage) Date() time.Time {
|
||||
args := m.Called()
|
||||
return args.Get(0).(time.Time)
|
||||
@@ -141,9 +156,9 @@ func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
|
||||
return args.Get(0).(*mail.Message), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockMessage) ReadBody() (body *enmime.MIMEBody, err error) {
|
||||
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)
|
||||
}
|
||||
|
||||
func (m *MockMessage) ReadRaw() (raw *string, err error) {
|
||||
|
||||
@@ -182,8 +182,8 @@ LOOP:
|
||||
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")
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* 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)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
Modal.VERSION = '3.3.6'
|
||||
Modal.VERSION = '3.3.7'
|
||||
|
||||
Modal.TRANSITION_DURATION = 300
|
||||
Modal.BACKDROP_TRANSITION_DURATION = 150
|
||||
@@ -140,7 +140,9 @@
|
||||
$(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))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* 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)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* 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)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
this.process()
|
||||
}
|
||||
|
||||
ScrollSpy.VERSION = '3.3.6'
|
||||
ScrollSpy.VERSION = '3.3.7'
|
||||
|
||||
ScrollSpy.DEFAULTS = {
|
||||
offset: 10
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* 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)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// jscs:enable requireDollarBeforejQueryAssignment
|
||||
}
|
||||
|
||||
Tab.VERSION = '3.3.6'
|
||||
Tab.VERSION = '3.3.7'
|
||||
|
||||
Tab.TRANSITION_DURATION = 150
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* ========================================================================
|
||||
* 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)
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
this.init('tooltip', element, options)
|
||||
}
|
||||
|
||||
Tooltip.VERSION = '3.3.6'
|
||||
Tooltip.VERSION = '3.3.7'
|
||||
|
||||
Tooltip.TRANSITION_DURATION = 150
|
||||
|
||||
@@ -317,9 +317,11 @@
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -362,7 +364,10 @@
|
||||
// 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
|
||||
|
||||
@@ -478,6 +483,7 @@
|
||||
that.$tip = null
|
||||
that.$arrow = null
|
||||
that.$viewport = null
|
||||
that.$element = null
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* ========================================================================
|
||||
* 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)
|
||||
* ======================================================================== */
|
||||
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
.border-right-radius(0);
|
||||
}
|
||||
}
|
||||
// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it
|
||||
// Need .dropdown-toggle since :last-child doesn't apply, given that a .dropdown-menu is used immediately after it
|
||||
.btn-group > .btn:last-child:not(:first-child),
|
||||
.btn-group > .dropdown-toggle:not(:first-child) {
|
||||
.border-left-radius(0);
|
||||
|
||||
@@ -181,7 +181,7 @@ input[type="search"] {
|
||||
// set a pixel line-height that matches the given height of the input, but only
|
||||
// for Safari. See https://bugs.webkit.org/show_bug.cgi?id=139848
|
||||
//
|
||||
// Note that as of 8.3, iOS doesn't support `datetime` or `week`.
|
||||
// Note that as of 9.3, iOS doesn't support `week`.
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
input[type="date"],
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
|
||||
|
||||
&:focus {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// WebKit-style focus
|
||||
|
||||
.tab-focus() {
|
||||
// Default
|
||||
outline: thin dotted;
|
||||
// WebKit
|
||||
// WebKit-specific. Other browsers will keep their default outline style.
|
||||
// (Initially tried to also force default via `outline: initial`,
|
||||
// but that seems to erroneously remove the outline in Firefox altogether.)
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
}
|
||||
|
||||
|
||||
// Collapsable panels (aka, accordion)
|
||||
// Collapsible panels (aka, accordion)
|
||||
//
|
||||
// Wrap a series of panels in `.panel-group` to turn them into an accordion with
|
||||
// the help of our collapse JavaScript plugin.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user