mirror of
https://gitlab.com/ric_harvey/MailHog.git
synced 2024-11-27 16:24:04 +00:00
Introducing Jim, the Chaos Monkey
This commit is contained in:
parent
9e8d990e72
commit
69fd88323f
9 changed files with 262 additions and 8 deletions
53
JIM.md
Normal file
53
JIM.md
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
Introduction to Jim
|
||||||
|
===================
|
||||||
|
|
||||||
|
Jim is the MailHog Chaos Monkey, inspired by Netflix.
|
||||||
|
|
||||||
|
You can invite Jim to the party using the `invite-jim` flag:
|
||||||
|
|
||||||
|
MailHog -invite-jim
|
||||||
|
|
||||||
|
With Jim around, things aren't going to work how you expect.
|
||||||
|
|
||||||
|
### What can Jim do?
|
||||||
|
|
||||||
|
* Reject connections
|
||||||
|
* Rate limit connections
|
||||||
|
* Reject authentication
|
||||||
|
* Reject senders
|
||||||
|
* Reject recipients
|
||||||
|
|
||||||
|
It does this randomly, but within defined parameters.
|
||||||
|
|
||||||
|
You can control these using the following command line flags:
|
||||||
|
|
||||||
|
| Flag | Default | Description
|
||||||
|
| --------------------- | ------- | ----
|
||||||
|
| -invite-jim | false | Set to true to invite Jim
|
||||||
|
| -jim-disconnect | 0.005 | Chance of randomly disconnecting a session
|
||||||
|
| -jim-accept | 0.99 | Chance of accepting an incoming connection
|
||||||
|
| -jim-linkspeed-affect | 0.1 | Chance of applying a rate limit
|
||||||
|
| -jim-linkspeed-min | 1024 | Minimum link speed (in bytes per second)
|
||||||
|
| -jim-linkspeed-max | 10240 | Maximum link speed (in bytes per second)
|
||||||
|
| -jim-reject-sender | 0.05 | Chance of rejecting a MAIL FROM command
|
||||||
|
| -jim-reject-recipient | 0.05 | Chance of rejecting a RCPT TO command
|
||||||
|
| -jim-reject-auth | 0.05 | Chance of rejecting an AUTH command
|
||||||
|
|
||||||
|
If you enable Jim, you enable all parts. To disable individual parts, set the chance
|
||||||
|
of it happening to 0, e.g. to disable connection rate limiting:
|
||||||
|
|
||||||
|
MailHog -invite-jim -jim-linkspeed-affect=0
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Always rate limit to 1 byte per second:
|
||||||
|
|
||||||
|
MailHog -invite-jim -jim-linkspeed-affect=1 -jim-linkspeed-max=1 -jim-linkspeed-min=1
|
||||||
|
|
||||||
|
Disconnect clients after approximately 5 commands:
|
||||||
|
|
||||||
|
MailHog -invite-jim -jim-disconnect=0.2
|
||||||
|
|
||||||
|
Simulate a mobile connection (at 10-100kbps) for 10% of clients:
|
||||||
|
|
||||||
|
MailHog -invite-jim -jim-linkspeed-affect=0.1 -jim-linkspeed-min=1250 -jim-linkspeed-max=12500
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/ian-kent/Go-MailHog/MailHog-Server/monkey"
|
||||||
"github.com/ian-kent/Go-MailHog/data"
|
"github.com/ian-kent/Go-MailHog/data"
|
||||||
"github.com/ian-kent/Go-MailHog/storage"
|
"github.com/ian-kent/Go-MailHog/storage"
|
||||||
"github.com/ian-kent/envconf"
|
"github.com/ian-kent/envconf"
|
||||||
|
@ -30,12 +31,15 @@ type Config struct {
|
||||||
MongoDb string
|
MongoDb string
|
||||||
MongoColl string
|
MongoColl string
|
||||||
StorageType string
|
StorageType string
|
||||||
|
InviteJim bool
|
||||||
Storage storage.Storage
|
Storage storage.Storage
|
||||||
MessageChan chan *data.Message
|
MessageChan chan *data.Message
|
||||||
Assets func(asset string) ([]byte, error)
|
Assets func(asset string) ([]byte, error)
|
||||||
|
Monkey monkey.ChaosMonkey
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg = DefaultConfig()
|
var cfg = DefaultConfig()
|
||||||
|
var jim = &monkey.Jim{}
|
||||||
|
|
||||||
func Configure() *Config {
|
func Configure() *Config {
|
||||||
switch cfg.StorageType {
|
switch cfg.StorageType {
|
||||||
|
@ -56,6 +60,13 @@ func Configure() *Config {
|
||||||
log.Fatalf("Invalid storage type %s", cfg.StorageType)
|
log.Fatalf("Invalid storage type %s", cfg.StorageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.InviteJim {
|
||||||
|
jim.Configure(func(message string, args ...interface{}) {
|
||||||
|
log.Printf(message, args...)
|
||||||
|
})
|
||||||
|
cfg.Monkey = jim
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,4 +78,6 @@ func RegisterFlags() {
|
||||||
flag.StringVar(&cfg.MongoUri, "mongouri", envconf.FromEnvP("MH_MONGO_URI", "127.0.0.1:27017").(string), "MongoDB URI, e.g. 127.0.0.1:27017")
|
flag.StringVar(&cfg.MongoUri, "mongouri", envconf.FromEnvP("MH_MONGO_URI", "127.0.0.1:27017").(string), "MongoDB URI, e.g. 127.0.0.1:27017")
|
||||||
flag.StringVar(&cfg.MongoDb, "mongodb", envconf.FromEnvP("MH_MONGO_DB", "mailhog").(string), "MongoDB database, e.g. mailhog")
|
flag.StringVar(&cfg.MongoDb, "mongodb", envconf.FromEnvP("MH_MONGO_DB", "mailhog").(string), "MongoDB database, e.g. mailhog")
|
||||||
flag.StringVar(&cfg.MongoColl, "mongocoll", envconf.FromEnvP("MH_MONGO_COLLECTION", "messages").(string), "MongoDB collection, e.g. messages")
|
flag.StringVar(&cfg.MongoColl, "mongocoll", envconf.FromEnvP("MH_MONGO_COLLECTION", "messages").(string), "MongoDB collection, e.g. messages")
|
||||||
|
flag.BoolVar(&cfg.InviteJim, "invite-jim", envconf.FromEnvP("MH_INVITE_JIM", false).(bool), "Decide whether to invite Jim (beware, he causes trouble)")
|
||||||
|
jim.RegisterFlags()
|
||||||
}
|
}
|
||||||
|
|
105
MailHog-Server/monkey/jim.go
Normal file
105
MailHog-Server/monkey/jim.go
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
package monkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ian-kent/linkio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Jim is a chaos monkey
|
||||||
|
type Jim struct {
|
||||||
|
disconnectChance float64
|
||||||
|
acceptChance float64
|
||||||
|
linkSpeedAffect float64
|
||||||
|
linkSpeedMin float64
|
||||||
|
linkSpeedMax float64
|
||||||
|
rejectSenderChance float64
|
||||||
|
rejectRecipientChance float64
|
||||||
|
rejectAuthChance float64
|
||||||
|
logf func(message string, args ...interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFlags implements ChaosMonkey.RegisterFlags
|
||||||
|
func (j *Jim) RegisterFlags() {
|
||||||
|
flag.Float64Var(&j.disconnectChance, "jim-disconnect", 0.005, "Chance of disconnect")
|
||||||
|
flag.Float64Var(&j.acceptChance, "jim-accept", 0.99, "Chance of accept")
|
||||||
|
flag.Float64Var(&j.linkSpeedAffect, "jim-linkspeed-affect", 0.1, "Chance of affecting link speed")
|
||||||
|
flag.Float64Var(&j.linkSpeedMin, "jim-linkspeed-min", 1024, "Minimum link speed (in bytes per second)")
|
||||||
|
flag.Float64Var(&j.linkSpeedMax, "jim-linkspeed-max", 10240, "Maximum link speed (in bytes per second)")
|
||||||
|
flag.Float64Var(&j.rejectSenderChance, "jim-reject-sender", 0.05, "Chance of rejecting a sender (MAIL FROM)")
|
||||||
|
flag.Float64Var(&j.rejectRecipientChance, "jim-reject-recipient", 0.05, "Chance of rejecting a recipient (RCPT TO)")
|
||||||
|
flag.Float64Var(&j.rejectAuthChance, "jim-reject-auth", 0.05, "Chance of rejecting authentication (AUTH)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure implements ChaosMonkey.Configure
|
||||||
|
func (j *Jim) Configure(logf func(string, ...interface{})) {
|
||||||
|
j.logf = logf
|
||||||
|
rand.Seed(time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept implements ChaosMonkey.Accept
|
||||||
|
func (j *Jim) Accept(conn net.Conn) bool {
|
||||||
|
if rand.Float64() > j.acceptChance {
|
||||||
|
j.logf("Jim: Rejecting connection\n")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j.logf("Jim: Allowing connection\n")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkSpeed implements ChaosMonkey.LinkSpeed
|
||||||
|
func (j *Jim) LinkSpeed() *linkio.Throughput {
|
||||||
|
rand.Seed(time.Now().Unix())
|
||||||
|
if rand.Float64() < j.linkSpeedAffect {
|
||||||
|
lsDiff := j.linkSpeedMax - j.linkSpeedMin
|
||||||
|
lsAffect := j.linkSpeedMin + (lsDiff * rand.Float64())
|
||||||
|
f := linkio.Throughput(lsAffect) * linkio.BytePerSecond
|
||||||
|
j.logf("Jim: Restricting throughput to %s\n", f)
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
j.logf("Jim: Allowing unrestricted throughput")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidRCPT implements ChaosMonkey.ValidRCPT
|
||||||
|
func (j *Jim) ValidRCPT(rcpt string) bool {
|
||||||
|
if rand.Float64() < j.rejectRecipientChance {
|
||||||
|
j.logf("Jim: Rejecting recipient %s\n", rcpt)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j.logf("Jim: Allowing recipient%s\n", rcpt)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidMAIL implements ChaosMonkey.ValidMAIL
|
||||||
|
func (j *Jim) ValidMAIL(mail string) bool {
|
||||||
|
if rand.Float64() < j.rejectSenderChance {
|
||||||
|
j.logf("Jim: Rejecting sender %s\n", mail)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j.logf("Jim: Allowing sender %s\n", mail)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidAUTH implements ChaosMonkey.ValidAUTH
|
||||||
|
func (j *Jim) ValidAUTH(mechanism string, args ...string) bool {
|
||||||
|
if rand.Float64() < j.rejectAuthChance {
|
||||||
|
j.logf("Jim: Rejecting authentication %s: %s\n", mechanism, args)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j.logf("Jim: Allowing authentication %s: %s\n", mechanism, args)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect implements ChaosMonkey.Disconnect
|
||||||
|
func (j *Jim) Disconnect() bool {
|
||||||
|
if rand.Float64() < j.disconnectChance {
|
||||||
|
j.logf("Jim: Being nasty, kicking them off\n")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
j.logf("Jim: Being nice, letting them stay\n")
|
||||||
|
return false
|
||||||
|
}
|
28
MailHog-Server/monkey/monkey.go
Normal file
28
MailHog-Server/monkey/monkey.go
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
package monkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/ian-kent/linkio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChaosMonkey should be implemented by chaos monkeys!
|
||||||
|
type ChaosMonkey interface {
|
||||||
|
RegisterFlags()
|
||||||
|
Configure(func(string, ...interface{}))
|
||||||
|
|
||||||
|
// Accept is called for each incoming connection. Returning false closes the connection.
|
||||||
|
Accept(conn net.Conn) bool
|
||||||
|
// LinkSpeed sets the maximum connection throughput (in one direction)
|
||||||
|
LinkSpeed() *linkio.Throughput
|
||||||
|
|
||||||
|
// ValidRCPT is called for the RCPT command. Returning false signals an invalid recipient.
|
||||||
|
ValidRCPT(rcpt string) bool
|
||||||
|
// ValidMAIL is called for the MAIL command. Returning false signals an invalid sender.
|
||||||
|
ValidMAIL(mail string) bool
|
||||||
|
// ValidAUTH is called after authentication. Returning false signals invalid authentication.
|
||||||
|
ValidAUTH(mechanism string, args ...string) bool
|
||||||
|
|
||||||
|
// Disconnect is called after every read. Returning true will close the connection.
|
||||||
|
Disconnect() bool
|
||||||
|
}
|
|
@ -7,9 +7,11 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ian-kent/Go-MailHog/MailHog-Server/monkey"
|
||||||
"github.com/ian-kent/Go-MailHog/data"
|
"github.com/ian-kent/Go-MailHog/data"
|
||||||
"github.com/ian-kent/Go-MailHog/smtp/protocol"
|
"github.com/ian-kent/Go-MailHog/smtp/protocol"
|
||||||
"github.com/ian-kent/Go-MailHog/storage"
|
"github.com/ian-kent/Go-MailHog/storage"
|
||||||
|
"github.com/ian-kent/linkio"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Session represents a SMTP session using net.TCPConn
|
// Session represents a SMTP session using net.TCPConn
|
||||||
|
@ -21,13 +23,32 @@ type Session struct {
|
||||||
remoteAddress string
|
remoteAddress string
|
||||||
isTLS bool
|
isTLS bool
|
||||||
line string
|
line string
|
||||||
|
link *linkio.Link
|
||||||
|
|
||||||
|
reader io.Reader
|
||||||
|
writer io.Writer
|
||||||
|
monkey monkey.ChaosMonkey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept starts a new SMTP session using io.ReadWriteCloser
|
// Accept starts a new SMTP session using io.ReadWriteCloser
|
||||||
func Accept(remoteAddress string, conn io.ReadWriteCloser, storage storage.Storage, messageChan chan *data.Message, hostname string) {
|
func Accept(remoteAddress string, conn io.ReadWriteCloser, storage storage.Storage, messageChan chan *data.Message, hostname string, monkey monkey.ChaosMonkey) {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
proto := protocol.NewProtocol()
|
proto := protocol.NewProtocol()
|
||||||
proto.Hostname = hostname
|
proto.Hostname = hostname
|
||||||
session := &Session{conn, proto, storage, messageChan, remoteAddress, false, ""}
|
var link *linkio.Link
|
||||||
|
reader := io.Reader(conn)
|
||||||
|
writer := io.Writer(conn)
|
||||||
|
if monkey != nil {
|
||||||
|
linkSpeed := monkey.LinkSpeed()
|
||||||
|
if linkSpeed != nil {
|
||||||
|
link = linkio.NewLink(*linkSpeed * linkio.BytePerSecond)
|
||||||
|
reader = link.NewLinkReader(io.Reader(conn))
|
||||||
|
writer = link.NewLinkWriter(io.Writer(conn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &Session{conn, proto, storage, messageChan, remoteAddress, false, "", link, reader, writer, monkey}
|
||||||
proto.LogHandler = session.logf
|
proto.LogHandler = session.logf
|
||||||
proto.MessageReceivedHandler = session.acceptMessage
|
proto.MessageReceivedHandler = session.acceptMessage
|
||||||
proto.ValidateSenderHandler = session.validateSender
|
proto.ValidateSenderHandler = session.validateSender
|
||||||
|
@ -37,19 +58,42 @@ func Accept(remoteAddress string, conn io.ReadWriteCloser, storage storage.Stora
|
||||||
session.logf("Starting session")
|
session.logf("Starting session")
|
||||||
session.Write(proto.Start())
|
session.Write(proto.Start())
|
||||||
for session.Read() == true {
|
for session.Read() == true {
|
||||||
|
if monkey != nil && monkey.Disconnect != nil && monkey.Disconnect() {
|
||||||
|
session.conn.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
session.logf("Session ended")
|
session.logf("Session ended")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Session) validateAuthentication(mechanism string, args ...string) (errorReply *protocol.Reply, ok bool) {
|
func (c *Session) validateAuthentication(mechanism string, args ...string) (errorReply *protocol.Reply, ok bool) {
|
||||||
|
if c.monkey != nil {
|
||||||
|
ok := c.monkey.ValidAUTH(mechanism, args...)
|
||||||
|
if !ok {
|
||||||
|
// FIXME better error?
|
||||||
|
return protocol.ReplyUnrecognisedCommand(), false
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil, true
|
return nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Session) validateRecipient(to string) bool {
|
func (c *Session) validateRecipient(to string) bool {
|
||||||
|
if c.monkey != nil {
|
||||||
|
ok := c.monkey.ValidRCPT(to)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Session) validateSender(from string) bool {
|
func (c *Session) validateSender(from string) bool {
|
||||||
|
if c.monkey != nil {
|
||||||
|
ok := c.monkey.ValidMAIL(from)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +113,7 @@ func (c *Session) logf(message string, args ...interface{}) {
|
||||||
// Read reads from the underlying net.TCPConn
|
// Read reads from the underlying net.TCPConn
|
||||||
func (c *Session) Read() bool {
|
func (c *Session) Read() bool {
|
||||||
buf := make([]byte, 1024)
|
buf := make([]byte, 1024)
|
||||||
n, err := io.Reader(c.conn).Read(buf)
|
n, err := c.reader.Read(buf)
|
||||||
|
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
c.logf("Connection closed by remote host\n")
|
c.logf("Connection closed by remote host\n")
|
||||||
|
@ -112,6 +156,6 @@ func (c *Session) Write(reply *protocol.Reply) {
|
||||||
logText := strings.Replace(l, "\n", "\\n", -1)
|
logText := strings.Replace(l, "\n", "\\n", -1)
|
||||||
logText = strings.Replace(logText, "\r", "\\r", -1)
|
logText = strings.Replace(logText, "\r", "\\r", -1)
|
||||||
c.logf("Sent %d bytes: '%s'", len(l), logText)
|
c.logf("Sent %d bytes: '%s'", len(l), logText)
|
||||||
io.Writer(c.conn).Write([]byte(l))
|
c.writer.Write([]byte(l))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ func TestAccept(t *testing.T) {
|
||||||
Convey("Accept should handle a connection", t, func() {
|
Convey("Accept should handle a connection", t, func() {
|
||||||
frw := &fakeRw{}
|
frw := &fakeRw{}
|
||||||
mChan := make(chan *data.Message)
|
mChan := make(chan *data.Message)
|
||||||
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost")
|
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ func TestSocketError(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
mChan := make(chan *data.Message)
|
mChan := make(chan *data.Message)
|
||||||
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost")
|
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +98,7 @@ func TestAcceptMessage(t *testing.T) {
|
||||||
//So(m, ShouldNotBeNil)
|
//So(m, ShouldNotBeNil)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}()
|
}()
|
||||||
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost")
|
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
So(handlerCalled, ShouldBeTrue)
|
So(handlerCalled, ShouldBeTrue)
|
||||||
})
|
})
|
||||||
|
|
|
@ -22,7 +22,14 @@ func Listen(cfg *config.Config, exitCh chan int) *net.TCPListener {
|
||||||
log.Printf("[SMTP] Error accepting connection: %s\n", err)
|
log.Printf("[SMTP] Error accepting connection: %s\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
|
||||||
|
if cfg.Monkey != nil {
|
||||||
|
ok := cfg.Monkey.Accept(conn)
|
||||||
|
if !ok {
|
||||||
|
conn.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go Accept(
|
go Accept(
|
||||||
conn.(*net.TCPConn).RemoteAddr().String(),
|
conn.(*net.TCPConn).RemoteAddr().String(),
|
||||||
|
@ -30,6 +37,7 @@ func Listen(cfg *config.Config, exitCh chan int) *net.TCPListener {
|
||||||
cfg.Storage,
|
cfg.Storage,
|
||||||
cfg.MessageChan,
|
cfg.MessageChan,
|
||||||
cfg.Hostname,
|
cfg.Hostname,
|
||||||
|
cfg.Monkey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1
Makefile
1
Makefile
|
@ -25,6 +25,7 @@ deps:
|
||||||
go get github.com/ian-kent/go-log/log
|
go get github.com/ian-kent/go-log/log
|
||||||
go get github.com/ian-kent/envconf
|
go get github.com/ian-kent/envconf
|
||||||
go get github.com/ian-kent/goose
|
go get github.com/ian-kent/goose
|
||||||
|
go get github.com/ian-kent/linkio
|
||||||
go get github.com/jteeuwen/go-bindata/...
|
go get github.com/jteeuwen/go-bindata/...
|
||||||
go get labix.org/v2/mgo
|
go get labix.org/v2/mgo
|
||||||
# added to fix travis issues
|
# added to fix travis issues
|
||||||
|
|
|
@ -29,6 +29,8 @@ Go was chosen for portability - MailHog runs without installation on multiple pl
|
||||||
* Supports RFC2047 encoded headers
|
* Supports RFC2047 encoded headers
|
||||||
* Real-time updates using EventSource
|
* Real-time updates using EventSource
|
||||||
* Release messages to real SMTP servers
|
* Release messages to real SMTP servers
|
||||||
|
* Chaos Monkey for failure testing
|
||||||
|
* See [Introduction to Jim](JIM.md) for more information
|
||||||
* HTTP API to list, retrieve and delete messages
|
* HTTP API to list, retrieve and delete messages
|
||||||
* See [APIv1 documentation](APIv1.md) for more information
|
* See [APIv1 documentation](APIv1.md) for more information
|
||||||
* Multipart MIME support
|
* Multipart MIME support
|
||||||
|
|
Loading…
Reference in a new issue