Add HTTP server and assets

This commit is contained in:
Ian Kent 2014-04-20 15:35:59 +01:00
parent c4aebd5d66
commit 4f4f921013
11 changed files with 366 additions and 25 deletions

View file

@ -2,12 +2,20 @@ package mailhog
func DefaultConfig() *Config {
return &Config{
BindAddr: "0.0.0.0:1025",
SMTPBindAddr: "0.0.0.0:1025",
HTTPBindAddr: "0.0.0.0:8025",
Hostname: "mailhog.example",
MongoUri: "127.0.0.1:27017",
MongoDb: "mailhog",
MongoColl: "messages",
}
}
type Config struct {
BindAddr string
SMTPBindAddr string
HTTPBindAddr string
Hostname string
MongoUri string
MongoDb string
MongoColl string
}

View file

@ -89,6 +89,7 @@ func ContentFromString(data string) *Content {
if(strings.Contains(hdr, ": ")) {
y := strings.SplitN(hdr, ": ", 2)
key, value := y[0], y[1]
// TODO multiple header fields
h[key] = []string{value}
} else {
log.Printf("Found invalid header: '%s'", hdr)

50
mailhog/http/server.go Normal file
View file

@ -0,0 +1,50 @@
package http
import (
"fmt"
"net/http"
"github.com/ian-kent/MailHog/mailhog"
"github.com/ian-kent/MailHog/mailhog/templates"
"github.com/ian-kent/MailHog/mailhog/templates/images"
"github.com/ian-kent/MailHog/mailhog/templates/js"
)
var exitChannel chan int
func web_exit(w http.ResponseWriter, r *http.Request) {
web_headers(w)
fmt.Fprint(w, "Exiting MailHog!")
exitChannel <- 1
}
func web_index(w http.ResponseWriter, r *http.Request) {
web_headers(w)
fmt.Fprint(w, web_render(templates.Index()))
}
func web_jscontroller(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript")
fmt.Fprint(w, js.Controllers())
}
func web_imgcontroller(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(images.Hog())
}
func web_render(content string) string {
return templates.Layout(content)
}
func web_headers(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html")
}
func Start(exitCh chan int, conf *mailhog.Config) {
exitChannel = exitCh
http.HandleFunc("/exit", web_exit)
http.HandleFunc("/js/controllers.js", web_jscontroller)
http.HandleFunc("/images/hog.png", web_imgcontroller)
http.HandleFunc("/", web_index)
http.ListenAndServe(conf.HTTPBindAddr, nil)
}

View file

@ -28,7 +28,6 @@ const (
DONE
)
// TODO add Received/Return-Path headers
// TODO replace ".." lines with . in data
func StartSession(conn *net.TCPConn, conf *mailhog.Config) {
@ -39,7 +38,7 @@ func StartSession(conn *net.TCPConn, conf *mailhog.Config) {
}
func (c *Session) log(message string, args ...interface{}) {
message = strings.Join([]string{"[%s, %d]", message}, " ")
message = strings.Join([]string{"[SMTP %s, %d]", message}, " ")
args = append([]interface{}{c.conn.RemoteAddr(), c.state}, args...)
log.Printf(message, args...)
}

View file

@ -10,13 +10,13 @@ import (
func Store(c *mailhog.Config, m *data.SMTPMessage) (string, error) {
msg := data.ParseSMTPMessage(c, m)
session, err := mgo.Dial("localhost:27017")
session, err := mgo.Dial(c.MongoUri)
if(err != nil) {
log.Printf("Error connecting to MongoDB: %s", err)
return "", err
}
defer session.Close()
err = session.DB("mailhog").C("messages").Insert(msg)
err = session.DB(c.MongoDb).C(c.MongoColl).Insert(msg)
if err != nil {
log.Printf("Error inserting message: %s", err)
return "", err
@ -24,15 +24,15 @@ func Store(c *mailhog.Config, m *data.SMTPMessage) (string, error) {
return msg.Id, nil
}
func Load(id string) (*data.Message, error) {
session, err := mgo.Dial("localhost:27017")
func Load(c *mailhog.Config, id string) (*data.Message, error) {
session, err := mgo.Dial(c.MongoUri)
if(err != nil) {
log.Printf("Error connecting to MongoDB: %s", err)
return nil, err
}
defer session.Close()
result := &data.Message{}
err = session.DB("mailhog").C("messages").Find(bson.M{"id": id}).One(&result)
err = session.DB(c.MongoDb).C(c.MongoColl).Find(bson.M{"id": id}).One(&result)
if err != nil {
log.Printf("Error loading message: %s", err)
return nil, err

View file

@ -0,0 +1,60 @@
package images
import (
"encoding/base64"
)
func Hog() []byte {
hog := `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
qgAAAABJRU5ErkJggg==`
data, _ := base64.StdEncoding.DecodeString(hog)
return data
}

View file

@ -0,0 +1,90 @@
package templates
func Index() string {
return `
<style>
.messages {
height: 30%;
}
.preview {
height: 70%;
border-top: 1px solid #CCCCCC;
}
.preview #headers {
border-bottom: 1px solid #DDDDDD;
}
.selected {
background: #DADAFA;
}
table tbody {
overflow: scroll;
}
table td {
padding: 2px 4px 2px 4px !important;
}
</style>
<div class="modal fade" id="confirm-delete-all">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Delete all messages?</h4>
</div>
<div class="modal-body">
<p>Are you sure you want to delete all messages?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="deleteAllConfirm()">Delete all messages</button>
</div>
</div>
</div>
</div>
<div class="messages">
<table class="table">
<tr>
<th>From</th>
<th>To</th>
<th>Subject</th>
<th>Received</th>
<th>Actions</th>
</tr>
<tbody>
<tr ng-repeat="message in messages" ng-click="selectMessage(message)" ng-class="{ selected: message == preview }">
<td>
{{ message.from.mailbox }}@{{ message.from.domain }}
</td>
<td>
<span ng-repeat="to in message.to">
{{ to.mailbox }}@{{ to.domain }}
</span>
</td>
<td>
{{ message.content.headers.Subject }}
</td>
<td>
{{ date(message.created) }}
</td>
<td>
<button class="btn btn-xs btn-default" title="Delete" ng-click="deleteOne(message)"><span class="glyphicon glyphicon-remove"></span></button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="preview">
<table class="table" id="headers">
<tr ng-repeat="(header, value) in preview.content.headers">
<td>
{{ header }}
</td>
<td>
{{ value }}
</td>
</tr>
</table>
{{ preview.content.body }}
</div>
`;
}

View file

@ -0,0 +1,43 @@
package js
func Controllers() string {
return `
var mailhogApp = angular.module('mailhogApp', []);
mailhogApp.controller('MailCtrl', function ($scope, $http) {
$scope.refresh = function() {
$http.get('/api/v1/messages').success(function(data) {
$scope.messages = data;
});
}
$scope.refresh();
$scope.date = function(timestamp) {
return (new Date(timestamp)).toString();
};
$scope.selectMessage = function(message) {
$scope.preview = message;
}
$scope.deleteAll = function() {
$('#confirm-delete-all').modal('show');
}
$scope.deleteAllConfirm = function() {
$('#confirm-delete-all').modal('hide');
$http.post('/api/v1/messages/delete').success(function() {
$scope.refresh();
$scope.preview = null;
});
}
$scope.deleteOne = function(message) {
$http.post('/api/v1/messages/' + message._id + '/delete').success(function() {
if($scope.preview._id == message._id) $scope.preview = null;
$scope.refresh();
});
}
});
`;
}

View file

@ -0,0 +1,64 @@
package templates
import (
"strings"
)
func Layout(content string) string {
html := `
<!DOCTYPE html>
<html ng-app="mailhogApp">
<head>
<title>MailHog</title>
<script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap-theme.min.css">
<script src="//netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.15/angular.js"></script>
<script src="/js/controllers.js"></script>
<style>
body, html { height: 100%; overflow: none; }
.navbar {
margin-bottom: 0;
position: absolute;
top: 0;
right: 0;
width: 100%;
}
.messages {
padding-top: 50px;
}
.navbar-header img {
height: 35px;
margin: 8px 0 0 5px;
float: left;
}
.navbar-nav.navbar-right:last-child {
margin-right: 0; /* bootstrap fix?! */
}
</style>
</head>
<body ng-controller="MailCtrl">
<nav class="navbar navbar-default navbar-static-top" role="navigation">
<div class="navbar-header">
<img src="/images/hog.png">
<a class="navbar-brand" href="#">MailHog</a>
</div>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Options <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="#" ng-click="refresh()">Refresh</a></li>
<li class="divider"></li>
<li><a href="#" ng-click="deleteAll()">Delete all messages</a></li>
</ul>
</li>
<li><a target="_blank" href="https://github.com/ian-kent/MailHog">GitHub</a></li>
</ul>
</nav>
<%= content %>
</body>
</html>
`;
return strings.Replace(html, "<%= content %>", content, -1);
}

53
main.go
View file

@ -4,36 +4,70 @@ import (
"flag"
"log"
"net"
"os"
"github.com/ian-kent/MailHog/mailhog"
"github.com/ian-kent/MailHog/mailhog/http"
"github.com/ian-kent/MailHog/mailhog/smtp"
)
var conf *mailhog.Config
var exitCh chan int
func config() {
var listen, hostname string
var smtpbindaddr, httpbindaddr, hostname, mongouri, mongodb, mongocoll string
flag.StringVar(&listen, "listen", "0.0.0.0:1025", "Bind interface and port, e.g. 0.0.0.0:1025 or just :1025")
flag.StringVar(&smtpbindaddr, "smtpbindaddr", "0.0.0.0:1025", "SMTP bind interface and port, e.g. 0.0.0.0:1025 or just :1025")
flag.StringVar(&httpbindaddr, "httpbindaddr", "0.0.0.0:8025", "HTTP bind interface and port, e.g. 0.0.0.0:8025 or just :8025")
flag.StringVar(&hostname, "hostname", "mailhog.example", "Hostname for EHLO/HELO response, e.g. mailhog.example")
flag.StringVar(&mongouri, "mongouri", "127.0.0.1:27017", "MongoDB URI, e.g. 127.0.0.1:27017")
flag.StringVar(&mongodb, "mongodb", "mailhog", "MongoDB database, e.g. mailhog")
flag.StringVar(&mongocoll, "mongocoll", "messages", "MongoDB collection, e.g. messages")
flag.Parse()
conf = &mailhog.Config{
BindAddr: listen,
SMTPBindAddr: smtpbindaddr,
HTTPBindAddr: httpbindaddr,
Hostname: hostname,
MongoUri: mongouri,
MongoDb: mongodb,
MongoColl: mongocoll,
}
}
func main() {
config()
ln := listen(conf.BindAddr)
exitCh = make(chan int)
go web_listen()
go smtp_listen()
for {
select {
case <-exitCh:
log.Printf("Received exit signal")
os.Exit(0)
}
}
}
func web_listen() {
log.Printf("[HTTP] Binding to address: %s\n", conf.HTTPBindAddr)
http.Start(exitCh, conf)
}
func smtp_listen() (*net.TCPListener) {
log.Printf("[SMTP] Binding to address: %s\n", conf.SMTPBindAddr)
ln, err := net.Listen("tcp", conf.SMTPBindAddr)
if err != nil {
log.Fatalf("[SMTP] Error listening on socket: %s\n", err)
}
defer ln.Close()
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("Error accepting connection: %s\n", err)
log.Printf("[SMTP] Error accepting connection: %s\n", err)
continue
}
defer conn.Close()
@ -41,12 +75,3 @@ func main() {
go smtp.StartSession(conn.(*net.TCPConn), conf)
}
}
func listen(bind string) (*net.TCPListener) {
log.Printf("Binding to address: %s\n", bind)
ln, err := net.Listen("tcp", bind)
if err != nil {
log.Fatalf("Error listening on socket: %s\n", err)
}
return ln.(*net.TCPListener)
}

View file

@ -6,6 +6,7 @@ import (
"net"
"strings"
"regexp"
"github.com/ian-kent/MailHog/mailhog"
"github.com/ian-kent/MailHog/mailhog/storage"
)
@ -86,7 +87,7 @@ func TestBasicHappyPath(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, string(buf[0:n]), "221 Bye\n")
message, err := storage.Load(match[1])
message, err := storage.Load(mailhog.DefaultConfig(), match[1])
assert.Nil(t, err)
assert.NotNil(t, message)