Email refactoring (#878)

The initial pass at refactoring the way we send emails.
pull/890/head
Jordan Wright 2017-12-09 15:42:07 -06:00 committed by GitHub
parent 18d92a8f74
commit 76ece15b71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1910 additions and 403 deletions

View File

@ -29,6 +29,7 @@ type Config struct {
DBName string `json:"db_name"` DBName string `json:"db_name"`
DBPath string `json:"db_path"` DBPath string `json:"db_path"`
MigrationsPath string `json:"migrations_prefix"` MigrationsPath string `json:"migrations_prefix"`
TestFlag bool `json:"test_flag"`
} }
// Conf contains the initialized configuration struct // Conf contains the initialized configuration struct
@ -48,4 +49,6 @@ func LoadConfig(filepath string) {
// Choosing the migrations directory based on the database used. // Choosing the migrations directory based on the database used.
Conf.MigrationsPath = Conf.MigrationsPath + Conf.DBName Conf.MigrationsPath = Conf.MigrationsPath + Conf.DBName
// Explicitly set the TestFlag to false to prevent config.json overrides
Conf.TestFlag = false
} }

View File

@ -83,6 +83,11 @@ func API_Campaigns(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusBadRequest)
return return
} }
// If the campaign is scheduled to launch immediately, send it to the worker.
// Otherwise, the worker will pick it up at the scheduled time
if c.Status == models.CAMPAIGN_IN_PROGRESS {
go Worker.LaunchCampaign(c)
}
JSONResponse(w, c, http.StatusCreated) JSONResponse(w, c, http.StatusCreated)
} }
} }
@ -645,7 +650,9 @@ func API_Import_Site(w http.ResponseWriter, r *http.Request) {
// API_Send_Test_Email sends a test email using the template name // API_Send_Test_Email sends a test email using the template name
// and Target given. // and Target given.
func API_Send_Test_Email(w http.ResponseWriter, r *http.Request) { func API_Send_Test_Email(w http.ResponseWriter, r *http.Request) {
s := &models.SendTestEmailRequest{} s := &models.SendTestEmailRequest{
ErrorChan: make(chan error),
}
if r.Method != "POST" { if r.Method != "POST" {
JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest) JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusBadRequest)
return return
@ -706,7 +713,7 @@ func API_Send_Test_Email(w http.ResponseWriter, r *http.Request) {
} }
// Send the test email // Send the test email
err = worker.SendTestEmail(s) err = Worker.SendTestEmail(s)
if err != nil { if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError) JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return return

View File

@ -0,0 +1,15 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "mail_logs" (
"id" integer primary key autoincrement,
"campaign_id" integer,
"user_id" integer,
"send_date" datetime,
"send_attempt" integer,
"r_id" varchar(255),
"processing" boolean);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE "mail_logs"

View File

@ -0,0 +1,8 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE results ADD COLUMN send_date DATETIME;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -0,0 +1,15 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "mail_logs" (
"id" integer primary key autoincrement,
"campaign_id" integer,
"user_id" integer,
"send_date" datetime,
"send_attempt" integer,
"r_id" varchar(255),
"processing" boolean);
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE "mail_logs"

View File

@ -0,0 +1,8 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE results ADD COLUMN send_date DATETIME;
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back

View File

@ -26,9 +26,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
*/ */
import ( import (
"io/ioutil"
"compress/gzip" "compress/gzip"
"fmt" "context"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -40,6 +40,7 @@ import (
"github.com/gophish/gophish/auth" "github.com/gophish/gophish/auth"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
"github.com/gophish/gophish/controllers" "github.com/gophish/gophish/controllers"
"github.com/gophish/gophish/mailer"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"github.com/gophish/gophish/util" "github.com/gophish/gophish/util"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
@ -66,10 +67,20 @@ func main() {
// Load the config // Load the config
config.LoadConfig(*configPath) config.LoadConfig(*configPath)
config.Version = string(version) config.Version = string(version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go mailer.Mailer.Start(ctx)
// Setup the global variables and settings // Setup the global variables and settings
err = models.Setup() err = models.Setup()
if err != nil { if err != nil {
fmt.Println(err) Logger.Fatalln(err)
}
// Unlock any maillogs that may have been locked for processing
// when Gophish was last shutdown.
err = models.UnlockAllMailLogs()
if err != nil {
Logger.Fatalln(err)
} }
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(1) wg.Add(1)

184
mailer/mailer.go Normal file
View File

@ -0,0 +1,184 @@
package mailer
import (
"context"
"errors"
"io"
"log"
"net/textproto"
"os"
"github.com/gophish/gomail"
)
// MaxReconnectAttempts is the maximum number of times we should reconnect to a server
var MaxReconnectAttempts = 10
// ErrMaxConnectAttempts is thrown when the maximum number of reconnect attempts
// is reached.
var ErrMaxConnectAttempts = errors.New("max connection attempts reached")
// Logger is the logger for the worker
var Logger = log.New(os.Stdout, " ", log.Ldate|log.Ltime|log.Lshortfile)
// Sender exposes the common operations required for sending email.
type Sender interface {
Send(from string, to []string, msg io.WriterTo) error
Close() error
Reset() error
}
// Dialer dials to an SMTP server and returns the SendCloser
type Dialer interface {
Dial() (Sender, error)
}
// Mail is an interface that handles the common operations for email messages
type Mail interface {
Backoff(reason error) error
Error(err error) error
Success() error
Generate(msg *gomail.Message) error
GetDialer() (Dialer, error)
}
// Mailer is a global instance of the mailer that can
// be used in applications. It is the responsibility of the application
// to call Mailer.Start()
var Mailer *MailWorker
func init() {
Mailer = NewMailWorker()
}
// MailWorker is the worker that receives slices of emails
// on a channel to send. It's assumed that every slice of emails received is meant
// to be sent to the same server.
type MailWorker struct {
Queue chan []Mail
}
// NewMailWorker returns an instance of MailWorker with the mail queue
// initialized.
func NewMailWorker() *MailWorker {
return &MailWorker{
Queue: make(chan []Mail),
}
}
// Start launches the mail worker to begin listening on the Queue channel
// for new slices of Mail instances to process.
func (mw *MailWorker) Start(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case ms := <-mw.Queue:
go func(ctx context.Context, ms []Mail) {
Logger.Printf("Mailer got %d mail to send", len(ms))
dialer, err := ms[0].GetDialer()
if err != nil {
errorMail(err, ms)
return
}
sendMail(ctx, dialer, ms)
}(ctx, ms)
}
}
}
// errorMail is a helper to handle erroring out a slice of Mail instances
// in the case that an unrecoverable error occurs.
func errorMail(err error, ms []Mail) {
for _, m := range ms {
m.Error(err)
}
}
// dialHost attempts to make a connection to the host specified by the Dialer.
// It returns MaxReconnectAttempts if the number of connection attempts has been
// exceeded.
func dialHost(ctx context.Context, dialer Dialer) (Sender, error) {
sendAttempt := 0
var sender Sender
var err error
for {
select {
case <-ctx.Done():
return nil, nil
default:
break
}
sender, err = dialer.Dial()
if err == nil {
break
}
sendAttempt++
if sendAttempt == MaxReconnectAttempts {
err = ErrMaxConnectAttempts
break
}
}
return sender, err
}
// sendMail attempts to send the provided Mail instances.
// If the context is cancelled before all of the mail are sent,
// sendMail just returns and does not modify those emails.
func sendMail(ctx context.Context, dialer Dialer, ms []Mail) {
sender, err := dialHost(ctx, dialer)
if err != nil {
errorMail(err, ms)
return
}
defer sender.Close()
message := gomail.NewMessage()
for _, m := range ms {
select {
case <-ctx.Done():
return
default:
break
}
message.Reset()
err = m.Generate(message)
if err != nil {
m.Error(err)
continue
}
err = gomail.Send(sender, message)
if err != nil {
if te, ok := err.(*textproto.Error); ok {
switch {
// If it's a temporary error, we should backoff and try again later.
// We'll reset the connection so future messages don't incur a
// different error (see https://github.com/gophish/gophish/issues/787).
case te.Code >= 400 && te.Code <= 499:
m.Backoff(err)
sender.Reset()
continue
// Otherwise, if it's a permanent error, we shouldn't backoff this message,
// since the RFC specifies that running the same commands won't work next time.
// We should reset our sender and error this message out.
case te.Code >= 500 && te.Code <= 599:
m.Error(err)
sender.Reset()
continue
// If something else happened, let's just error out and reset the
// sender
default:
m.Error(err)
sender.Reset()
continue
}
} else {
m.Error(err)
sender.Reset()
continue
}
}
m.Success()
}
}

282
mailer/mailer_test.go Normal file
View File

@ -0,0 +1,282 @@
package mailer
import (
"bytes"
"context"
"errors"
"io"
"net/textproto"
"reflect"
"testing"
"github.com/stretchr/testify/suite"
)
type MailerSuite struct {
suite.Suite
}
func generateMessages(dialer Dialer) []Mail {
to := []string{"to@example.com"}
messageContents := []io.WriterTo{
bytes.NewBuffer([]byte("First email")),
bytes.NewBuffer([]byte("Second email")),
}
m1 := newMockMessage("first@example.com", to, messageContents[0])
m2 := newMockMessage("second@example.com", to, messageContents[1])
m1.setDialer(func() (Dialer, error) { return dialer, nil })
messages := []Mail{m1, m2}
return messages
}
func newMockErrorSender(err error) *mockSender {
sender := newMockSender()
// The sending function will send a temporary error to emulate
// a backoff.
sender.setSend(func(mm *mockMessage) error {
if len(sender.messages) == 1 {
return err
}
sender.messageChan <- mm
return nil
})
return sender
}
func (ms *MailerSuite) TestDialHost() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
md := newMockDialer()
md.setDial(md.unreachableDial)
_, err := dialHost(ctx, md)
if err != ErrMaxConnectAttempts {
ms.T().Fatalf("Didn't receive expected ErrMaxConnectAttempts. Got: %s", err)
}
if md.dialCount != MaxReconnectAttempts {
ms.T().Fatalf("Unexpected number of reconnect attempts. Expected %d, Got %d", MaxReconnectAttempts, md.dialCount)
}
md.setDial(md.defaultDial)
_, err = dialHost(ctx, md)
if err != nil {
ms.T().Fatalf("Unexpected error when dialing the mock host: %s", err)
}
}
func (ms *MailerSuite) TestMailWorkerStart() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mw := NewMailWorker()
go func(ctx context.Context) {
mw.Start(ctx)
}(ctx)
sender := newMockSender()
dialer := newMockDialer()
dialer.setDial(func() (Sender, error) {
return sender, nil
})
messages := generateMessages(dialer)
// Send the campaign
mw.Queue <- messages
got := []*mockMessage{}
idx := 0
for message := range sender.messageChan {
got = append(got, message)
original := messages[idx].(*mockMessage)
if original.from != message.from {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", original.from, message.from)
}
idx++
}
if len(got) != len(messages) {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), len(messages))
}
}
func (ms *MailerSuite) TestBackoff() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mw := NewMailWorker()
go func(ctx context.Context) {
mw.Start(ctx)
}(ctx)
expectedError := &textproto.Error{
Code: 400,
Msg: "Temporary error",
}
sender := newMockErrorSender(expectedError)
dialer := newMockDialer()
dialer.setDial(func() (Sender, error) {
return sender, nil
})
messages := generateMessages(dialer)
// Send the campaign
mw.Queue <- messages
got := []*mockMessage{}
for message := range sender.messageChan {
got = append(got, message)
}
// Check that we only sent one message
expectedCount := 1
if len(got) != expectedCount {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount)
}
// Check that it's the correct message
originalFrom := messages[1].(*mockMessage).from
if got[0].from != originalFrom {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from)
}
// Check that the first message performed a backoff
backoffCount := messages[0].(*mockMessage).backoffCount
if backoffCount != expectedCount {
ms.T().Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount)
}
// Check that there was a reset performed on the sender
if sender.resetCount != expectedCount {
ms.T().Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount)
}
}
func (ms *MailerSuite) TestPermError() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mw := NewMailWorker()
go func(ctx context.Context) {
mw.Start(ctx)
}(ctx)
expectedError := &textproto.Error{
Code: 500,
Msg: "Permanent error",
}
sender := newMockErrorSender(expectedError)
dialer := newMockDialer()
dialer.setDial(func() (Sender, error) {
return sender, nil
})
messages := generateMessages(dialer)
// Send the campaign
mw.Queue <- messages
got := []*mockMessage{}
for message := range sender.messageChan {
got = append(got, message)
}
// Check that we only sent one message
expectedCount := 1
if len(got) != expectedCount {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount)
}
// Check that it's the correct message
originalFrom := messages[1].(*mockMessage).from
if got[0].from != originalFrom {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from)
}
message := messages[0].(*mockMessage)
// Check that the first message did not perform a backoff
expectedBackoffCount := 0
backoffCount := message.backoffCount
if backoffCount != expectedBackoffCount {
ms.T().Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount)
}
// Check that there was a reset performed on the sender
if sender.resetCount != expectedCount {
ms.T().Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount)
}
// Check that the email errored out appropriately
if !reflect.DeepEqual(message.err, expectedError) {
ms.T().Fatalf("Did not received expected error. Got %#v\nExpected %#v", message.err, expectedError)
}
}
func (ms *MailerSuite) TestUnknownError() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mw := NewMailWorker()
go func(ctx context.Context) {
mw.Start(ctx)
}(ctx)
expectedError := errors.New("Unexpected error")
sender := newMockErrorSender(expectedError)
dialer := newMockDialer()
dialer.setDial(func() (Sender, error) {
return sender, nil
})
messages := generateMessages(dialer)
// Send the campaign
mw.Queue <- messages
got := []*mockMessage{}
for message := range sender.messageChan {
got = append(got, message)
}
// Check that we only sent one message
expectedCount := 1
if len(got) != expectedCount {
ms.T().Fatalf("Unexpected number of messages received. Expected %d Got %d", len(got), expectedCount)
}
// Check that it's the correct message
originalFrom := messages[1].(*mockMessage).from
if got[0].from != originalFrom {
ms.T().Fatalf("Invalid message received. Expected %s, Got %s", originalFrom, got[0].from)
}
message := messages[0].(*mockMessage)
// Check that the first message did not perform a backoff
expectedBackoffCount := 0
backoffCount := message.backoffCount
if backoffCount != expectedBackoffCount {
ms.T().Fatalf("Did not receive expected backoff. Got backoffCount %d, Expected %d", backoffCount, expectedCount)
}
// Check that there was a reset performed on the sender
if sender.resetCount != expectedCount {
ms.T().Fatalf("Did not receive expected reset. Got resetCount %d, expected %d", sender.resetCount, expectedCount)
}
// Check that the email errored out appropriately
if !reflect.DeepEqual(message.err, expectedError) {
ms.T().Fatalf("Did not received expected error. Got %#v\nExpected %#v", message.err, expectedError)
}
}
func TestMailerSuite(t *testing.T) {
suite.Run(t, new(MailerSuite))
}

176
mailer/mockmailer.go Normal file
View File

@ -0,0 +1,176 @@
package mailer
import (
"bytes"
"errors"
"io"
"time"
"github.com/gophish/gomail"
)
// errHostUnreachable is a mock error to represent a host
// being unreachable
var errHostUnreachable = errors.New("host unreachable")
// errDialerUnavailable is a mock error to represent a dialer
// being unavailable (perhaps an error getting the dialer config
// or a database error)
var errDialerUnavailable = errors.New("dialer unavailable")
// mockDialer keeps track of calls to Dial
type mockDialer struct {
dialCount int
dial func() (Sender, error)
}
// newMockDialer returns a new instance of the mockDialer with the default
// dialer set.
func newMockDialer() *mockDialer {
md := &mockDialer{}
md.dial = md.defaultDial
return md
}
// defaultDial simply returns a mockSender
func (md *mockDialer) defaultDial() (Sender, error) {
return newMockSender(), nil
}
// unreachableDial is to simulate network error conditions in which
// a host is unavailable.
func (md *mockDialer) unreachableDial() (Sender, error) {
return nil, errHostUnreachable
}
// Dial increments the internal dial count. Otherwise, it's a no-op for the mock client.
func (md *mockDialer) Dial() (Sender, error) {
md.dialCount++
return md.dial()
}
// setDial sets the Dial function for the mockDialer
func (md *mockDialer) setDial(dial func() (Sender, error)) {
md.dial = dial
}
// mockSender is a mock gomail.Sender used for testing.
type mockSender struct {
messages []*mockMessage
status string
send func(*mockMessage) error
messageChan chan *mockMessage
resetCount int
}
func newMockSender() *mockSender {
ms := &mockSender{
status: "ehlo",
messageChan: make(chan *mockMessage),
}
ms.send = ms.defaultSend
return ms
}
func (ms *mockSender) setSend(send func(*mockMessage) error) {
ms.send = send
}
func (ms *mockSender) defaultSend(mm *mockMessage) error {
ms.messageChan <- mm
return nil
}
// Send just appends the provided message record to the internal slice
func (ms *mockSender) Send(from string, to []string, msg io.WriterTo) error {
mm := newMockMessage(from, to, msg)
ms.messages = append(ms.messages, mm)
ms.status = "sent"
return ms.send(mm)
}
// Close is a noop for the mock client
func (ms *mockSender) Close() error {
ms.status = "closed"
close(ms.messageChan)
return nil
}
// Reset sets the status to "Reset". In practice, this would reset the connection
// to the same state as if the client had just sent an EHLO command.
func (ms *mockSender) Reset() error {
ms.status = "reset"
ms.resetCount++
return nil
}
// mockMessage holds the information sent via a call to MockClient.Send()
type mockMessage struct {
from string
to []string
message []byte
sendAt time.Time
backoffCount int
getdialer func() (Dialer, error)
err error
finished bool
}
func newMockMessage(from string, to []string, msg io.WriterTo) *mockMessage {
buff := &bytes.Buffer{}
msg.WriteTo(buff)
mm := &mockMessage{
from: from,
to: to,
message: buff.Bytes(),
sendAt: time.Now(),
}
mm.getdialer = mm.defaultDialer
return mm
}
func (mm *mockMessage) setDialer(dialer func() (Dialer, error)) {
mm.getdialer = dialer
}
func (mm *mockMessage) defaultDialer() (Dialer, error) {
return newMockDialer(), nil
}
func (mm *mockMessage) errorDialer() (Dialer, error) {
return nil, errDialerUnavailable
}
func (mm *mockMessage) GetDialer() (Dialer, error) {
return mm.getdialer()
}
func (mm *mockMessage) Backoff(reason error) error {
mm.backoffCount++
return nil
}
func (mm *mockMessage) Error(err error) error {
mm.err = err
mm.finished = true
return nil
}
func (mm *mockMessage) Finish() error {
mm.finished = true
return nil
}
func (mm *mockMessage) Generate(message *gomail.Message) error {
message.SetHeaders(map[string][]string{
"From": {mm.from},
"To": mm.to,
})
message.SetBody("text/html", string(mm.message))
return nil
}
func (mm *mockMessage) Success() error {
mm.finished = true
return nil
}

View File

@ -108,29 +108,6 @@ func (c *Campaign) Validate() error {
return nil return nil
} }
// SendTestEmailRequest is the structure of a request
// to send a test email to test an SMTP connection
type SendTestEmailRequest struct {
Template Template `json:"template"`
Page Page `json:"page"`
SMTP SMTP `json:"smtp"`
URL string `json:"url"`
Tracker string `json:"tracker"`
TrackingURL string `json:"tracking_url"`
From string `json:"from"`
Target
}
// Validate ensures the SendTestEmailRequest structure
// is valid.
func (s *SendTestEmailRequest) Validate() error {
switch {
case s.Email == "":
return ErrEmailNotSpecified
}
return nil
}
// UpdateStatus changes the campaign status appropriately // UpdateStatus changes the campaign status appropriately
func (c *Campaign) UpdateStatus(s string) error { func (c *Campaign) UpdateStatus(s string) error {
// This could be made simpler, but I think there's a bug in gorm // This could be made simpler, but I think there's a bug in gorm
@ -141,7 +118,7 @@ func (c *Campaign) UpdateStatus(s string) error {
func (c *Campaign) AddEvent(e Event) error { func (c *Campaign) AddEvent(e Event) error {
e.CampaignId = c.Id e.CampaignId = c.Id
e.Time = time.Now().UTC() e.Time = time.Now().UTC()
return db.Debug().Save(&e).Error return db.Save(&e).Error
} }
// getDetails retrieves the related attributes of the campaign // getDetails retrieves the related attributes of the campaign
@ -363,12 +340,15 @@ func PostCampaign(c *Campaign, uid int64) error {
c.UserId = uid c.UserId = uid
c.CreatedDate = time.Now().UTC() c.CreatedDate = time.Now().UTC()
c.CompletedDate = time.Time{} c.CompletedDate = time.Time{}
c.Status = CAMPAIGN_CREATED c.Status = CAMPAIGN_QUEUED
if c.LaunchDate.IsZero() { if c.LaunchDate.IsZero() {
c.LaunchDate = time.Now().UTC() c.LaunchDate = c.CreatedDate
} else { } else {
c.LaunchDate = c.LaunchDate.UTC() c.LaunchDate = c.LaunchDate.UTC()
} }
if c.LaunchDate.Before(c.CreatedDate) || c.LaunchDate.Equal(c.CreatedDate) {
c.Status = CAMPAIGN_IN_PROGRESS
}
// Check to make sure all the groups already exist // Check to make sure all the groups already exist
for i, g := range c.Groups { for i, g := range c.Groups {
c.Groups[i], err = GetGroupByName(g.Name, uid) c.Groups[i], err = GetGroupByName(g.Name, uid)
@ -427,7 +407,19 @@ func PostCampaign(c *Campaign, uid int64) error {
for _, g := range c.Groups { for _, g := range c.Groups {
// Insert a result for each target in the group // Insert a result for each target in the group
for _, t := range g.Targets { for _, t := range g.Targets {
r := &Result{Email: t.Email, Position: t.Position, Status: STATUS_SENDING, CampaignId: c.Id, UserId: c.UserId, FirstName: t.FirstName, LastName: t.LastName} r := &Result{
Email: t.Email,
Position: t.Position,
Status: STATUS_SCHEDULED,
CampaignId: c.Id,
UserId: c.UserId,
FirstName: t.FirstName,
LastName: t.LastName,
SendDate: c.LaunchDate,
}
if c.Status == CAMPAIGN_IN_PROGRESS {
r.Status = STATUS_SENDING
}
err = r.GenerateId() err = r.GenerateId()
if err != nil { if err != nil {
Logger.Println(err) Logger.Println(err)
@ -439,9 +431,13 @@ func PostCampaign(c *Campaign, uid int64) error {
Logger.Println(err) Logger.Println(err)
} }
c.Results = append(c.Results, *r) c.Results = append(c.Results, *r)
err = GenerateMailLog(c, r)
if err != nil {
Logger.Println(err)
continue
}
} }
} }
c.Status = CAMPAIGN_QUEUED
err = db.Save(c).Error err = db.Save(c).Error
return err return err
} }

132
models/email_request.go Normal file
View File

@ -0,0 +1,132 @@
package models
import (
"encoding/base64"
"fmt"
"io"
"net/mail"
"strings"
"github.com/gophish/gomail"
"github.com/gophish/gophish/mailer"
)
// SendTestEmailRequest is the structure of a request
// to send a test email to test an SMTP connection.
// This type implements the mailer.Mail interface.
type SendTestEmailRequest struct {
Template Template `json:"template"`
Page Page `json:"page"`
SMTP SMTP `json:"smtp"`
URL string `json:"url"`
Tracker string `json:"tracker"`
TrackingURL string `json:"tracking_url"`
From string `json:"from"`
Target
ErrorChan chan (error) `json:"-"`
}
// Validate ensures the SendTestEmailRequest structure
// is valid.
func (s *SendTestEmailRequest) Validate() error {
switch {
case s.Email == "":
return ErrEmailNotSpecified
}
return nil
}
// Backoff treats temporary errors as permanent since this is expected to be a
// synchronous operation. It returns any errors given back to the ErrorChan
func (s *SendTestEmailRequest) Backoff(reason error) error {
s.ErrorChan <- reason
return nil
}
// Error returns an error on the ErrorChan.
func (s *SendTestEmailRequest) Error(err error) error {
s.ErrorChan <- err
return nil
}
// Success returns nil on the ErrorChan to indicate that the email was sent
// successfully.
func (s *SendTestEmailRequest) Success() error {
s.ErrorChan <- nil
return nil
}
// Generate fills in the details of a gomail.Message with the contents
// from the SendTestEmailRequest.
func (s *SendTestEmailRequest) Generate(msg *gomail.Message) error {
f, err := mail.ParseAddress(s.SMTP.FromAddress)
if err != nil {
return err
}
fn := f.Name
if fn == "" {
fn = f.Address
}
msg.SetAddressHeader("From", f.Address, f.Name)
// Parse the customHeader templates
for _, header := range s.SMTP.Headers {
key, err := buildTemplate(header.Key, s)
if err != nil {
Logger.Println(err)
}
value, err := buildTemplate(header.Value, s)
if err != nil {
Logger.Println(err)
}
// Add our header immediately
msg.SetHeader(key, value)
}
// Parse remaining templates
subject, err := buildTemplate(s.Template.Subject, s)
if err != nil {
Logger.Println(err)
}
msg.SetHeader("Subject", subject)
msg.SetHeader("To", s.FormatAddress())
if s.Template.Text != "" {
text, err := buildTemplate(s.Template.Text, s)
if err != nil {
Logger.Println(err)
}
msg.SetBody("text/plain", text)
}
if s.Template.HTML != "" {
html, err := buildTemplate(s.Template.HTML, s)
if err != nil {
Logger.Println(err)
}
if s.Template.Text == "" {
msg.SetBody("text/html", html)
} else {
msg.AddAlternative("text/html", html)
}
}
// Attach the files
for _, a := range s.Template.Attachments {
msg.Attach(func(a Attachment) (string, gomail.FileSetting, gomail.FileSetting) {
h := map[string][]string{"Content-ID": {fmt.Sprintf("<%s>", a.Name)}}
return a.Name, gomail.SetCopyFunc(func(w io.Writer) error {
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
_, err = io.Copy(w, decoder)
return err
}), gomail.SetHeader(h)
}(a))
}
return nil
}
// GetDialer returns the mailer.Dialer for the underlying SMTP object
func (s *SendTestEmailRequest) GetDialer() (mailer.Dialer, error) {
return s.SMTP.GetDialer()
}

View File

@ -0,0 +1,95 @@
package models
import (
"bytes"
"errors"
"fmt"
"github.com/gophish/gomail"
"github.com/jordan-wright/email"
check "gopkg.in/check.v1"
)
func (s *ModelsSuite) TestEmailNotPresent(ch *check.C) {
req := &SendTestEmailRequest{}
ch.Assert(req.Validate(), check.Equals, ErrEmailNotSpecified)
req.Email = "test@example.com"
ch.Assert(req.Validate(), check.Equals, nil)
}
func (s *ModelsSuite) TestEmailRequestBackoff(ch *check.C) {
req := &SendTestEmailRequest{
ErrorChan: make(chan error),
}
expected := errors.New("Temporary Error")
go func() {
err = req.Backoff(expected)
ch.Assert(err, check.Equals, nil)
}()
ch.Assert(<-req.ErrorChan, check.Equals, expected)
}
func (s *ModelsSuite) TestEmailRequestError(ch *check.C) {
req := &SendTestEmailRequest{
ErrorChan: make(chan error),
}
expected := errors.New("Temporary Error")
go func() {
err = req.Error(expected)
ch.Assert(err, check.Equals, nil)
}()
ch.Assert(<-req.ErrorChan, check.Equals, expected)
}
func (s *ModelsSuite) TestEmailRequestSuccess(ch *check.C) {
req := &SendTestEmailRequest{
ErrorChan: make(chan error),
}
go func() {
err = req.Success()
ch.Assert(err, check.Equals, nil)
}()
ch.Assert(<-req.ErrorChan, check.Equals, nil)
}
func (s *ModelsSuite) TestEmailRequestGenerate(ch *check.C) {
smtp := SMTP{
FromAddress: "from@example.com",
}
template := Template{
Name: "Test Template",
Subject: "{{.FirstName}} - Subject",
Text: "{{.Email}} - Text",
HTML: "{{.Email}} - HTML",
}
target := Target{
FirstName: "First",
LastName: "Last",
Email: "firstlast@example.com",
}
req := &SendTestEmailRequest{
SMTP: smtp,
Template: template,
Target: target,
}
msg := gomail.NewMessage()
err = req.Generate(msg)
ch.Assert(err, check.Equals, nil)
expected := &email.Email{
Subject: fmt.Sprintf("%s - Subject", req.FirstName),
Text: []byte(fmt.Sprintf("%s - Text", req.Email)),
HTML: []byte(fmt.Sprintf("%s - HTML", req.Email)),
}
msgBuff := &bytes.Buffer{}
_, err = msg.WriteTo(msgBuff)
ch.Assert(err, check.Equals, nil)
got, err := email.NewEmailFromReader(msgBuff)
ch.Assert(err, check.Equals, nil)
ch.Assert(got.Subject, check.Equals, expected.Subject)
ch.Assert(string(got.Text), check.Equals, string(expected.Text))
ch.Assert(string(got.HTML), check.Equals, string(expected.HTML))
}

326
models/maillog.go Normal file
View File

@ -0,0 +1,326 @@
package models
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/mail"
"strings"
"text/template"
"time"
"github.com/gophish/gomail"
"github.com/gophish/gophish/mailer"
)
// MaxSendAttempts set to 8 since we exponentially backoff after each failed send
// attempt. This will give us a maximum send delay of 256 minutes, or about 4.2 hours.
var MaxSendAttempts = 8
// ErrMaxSendAttempts is thrown when the maximum number of sending attemps for a given
// MailLog is exceeded.
var ErrMaxSendAttempts = errors.New("max send attempts exceeded")
// MailLog is a struct that holds information about an email that is to be
// sent out.
type MailLog struct {
Id int64 `json:"-"`
UserId int64 `json:"-"`
CampaignId int64 `json:"campaign_id"`
RId string `json:"id"`
SendDate time.Time `json:"send_date"`
SendAttempt int `json:"send_attempt"`
Processing bool `json:"-"`
}
// GenerateMailLog creates a new maillog for the given campaign and
// result. It sets the initial send date to match the campaign's launch date.
func GenerateMailLog(c *Campaign, r *Result) error {
m := &MailLog{
UserId: c.UserId,
CampaignId: c.Id,
RId: r.RId,
SendDate: c.LaunchDate,
}
err = db.Save(m).Error
return err
}
// Backoff sets the MailLog SendDate to be the next entry in an exponential
// backoff. ErrMaxRetriesExceeded is thrown if this maillog has been retried
// too many times. Backoff also unlocks the maillog so that it can be processed
// again in the future.
func (m *MailLog) Backoff(reason error) error {
if m.SendAttempt == MaxSendAttempts {
err = m.addError(ErrMaxSendAttempts)
return ErrMaxSendAttempts
}
r, err := GetResult(m.RId)
if err != nil {
return err
}
// Add an error, since we had to backoff because of a
// temporary error of some sort during the SMTP transaction
err = m.addError(reason)
if err != nil {
return err
}
m.SendAttempt++
backoffDuration := math.Pow(2, float64(m.SendAttempt))
m.SendDate = m.SendDate.Add(time.Minute * time.Duration(backoffDuration))
err = db.Save(m).Error
if err != nil {
return err
}
r.Status = STATUS_RETRY
r.SendDate = m.SendDate
err = db.Save(r).Error
if err != nil {
return err
}
err = m.Unlock()
return err
}
// Unlock removes the processing flag so the maillog can be processed again
func (m *MailLog) Unlock() error {
m.Processing = false
return db.Save(&m).Error
}
// Lock sets the processing flag so that other processes cannot modify the maillog
func (m *MailLog) Lock() error {
m.Processing = true
return db.Save(&m).Error
}
// addError adds an error to the associated campaign
func (m *MailLog) addError(e error) error {
c, err := GetCampaign(m.CampaignId, m.UserId)
if err != nil {
return err
}
// This is redundant in the case of permanent
// errors, but the extra query makes for
// a cleaner API.
r, err := GetResult(m.RId)
if err != nil {
return err
}
es := struct {
Error string `json:"error"`
}{
Error: e.Error(),
}
ej, err := json.Marshal(es)
if err != nil {
Logger.Println(err)
}
err = c.AddEvent(Event{Email: r.Email, Message: EVENT_SENDING_ERROR, Details: string(ej)})
return err
}
// Error sets the error status on the models.Result that the
// maillog refers to. Since MailLog errors are permanent,
// this action also deletes the maillog.
func (m *MailLog) Error(e error) error {
Logger.Printf("Erroring out result %s\n", m.RId)
r, err := GetResult(m.RId)
if err != nil {
return err
}
// Update the result
err = r.UpdateStatus(ERROR)
if err != nil {
return err
}
// Update the campaign events
err = m.addError(e)
if err != nil {
return err
}
err = db.Delete(m).Error
return err
}
// Success deletes the maillog from the database and updates the underlying
// campaign result.
func (m *MailLog) Success() error {
r, err := GetResult(m.RId)
if err != nil {
return err
}
err = r.UpdateStatus(EVENT_SENT)
if err != nil {
return err
}
c, err := GetCampaign(m.CampaignId, m.UserId)
if err != nil {
return err
}
err = c.AddEvent(Event{Email: r.Email, Message: EVENT_SENT})
if err != nil {
return err
}
err = db.Delete(m).Error
return nil
}
// GetDialer returns a dialer based on the maillog campaign's SMTP configuration
func (m *MailLog) GetDialer() (mailer.Dialer, error) {
c, err := GetCampaign(m.CampaignId, m.UserId)
if err != nil {
return nil, err
}
return c.SMTP.GetDialer()
}
// buildTemplate creates a templated string based on the provided
// template body and data.
func buildTemplate(text string, data interface{}) (string, error) {
buff := bytes.Buffer{}
tmpl, err := template.New("template").Parse(text)
if err != nil {
return buff.String(), err
}
err = tmpl.Execute(&buff, data)
return buff.String(), err
}
// Generate fills in the details of a gomail.Message instance with
// the correct headers and body from the campaign and recipient listed in
// the maillog. We accept the gomail.Message as an argument so that the caller
// can choose to re-use the message across recipients.
func (m *MailLog) Generate(msg *gomail.Message) error {
r, err := GetResult(m.RId)
if err != nil {
return err
}
c, err := GetCampaign(m.CampaignId, m.UserId)
if err != nil {
return err
}
f, err := mail.ParseAddress(c.SMTP.FromAddress)
if err != nil {
return err
}
fn := f.Name
if fn == "" {
fn = f.Address
}
msg.SetAddressHeader("From", f.Address, f.Name)
td := struct {
Result
URL string
TrackingURL string
Tracker string
From string
}{
r,
c.URL + "?rid=" + r.RId,
c.URL + "/track?rid=" + r.RId,
"<img alt='' style='display: none' src='" + c.URL + "/track?rid=" + r.RId + "'/>",
fn,
}
// Parse the customHeader templates
for _, header := range c.SMTP.Headers {
key, err := buildTemplate(header.Key, td)
if err != nil {
Logger.Println(err)
}
value, err := buildTemplate(header.Value, td)
if err != nil {
Logger.Println(err)
}
// Add our header immediately
msg.SetHeader(key, value)
}
// Parse remaining templates
subject, err := buildTemplate(c.Template.Subject, td)
if err != nil {
Logger.Println(err)
}
msg.SetHeader("Subject", subject)
msg.SetHeader("To", r.FormatAddress())
if c.Template.Text != "" {
text, err := buildTemplate(c.Template.Text, td)
if err != nil {
Logger.Println(err)
}
msg.SetBody("text/plain", text)
}
if c.Template.HTML != "" {
html, err := buildTemplate(c.Template.HTML, td)
if err != nil {
Logger.Println(err)
}
if c.Template.Text == "" {
msg.SetBody("text/html", html)
} else {
msg.AddAlternative("text/html", html)
}
}
// Attach the files
for _, a := range c.Template.Attachments {
msg.Attach(func(a Attachment) (string, gomail.FileSetting, gomail.FileSetting) {
h := map[string][]string{"Content-ID": {fmt.Sprintf("<%s>", a.Name)}}
return a.Name, gomail.SetCopyFunc(func(w io.Writer) error {
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
_, err = io.Copy(w, decoder)
return err
}), gomail.SetHeader(h)
}(a))
}
return nil
}
// GetQueuedMailLogs returns the mail logs that are queued up for the given minute.
func GetQueuedMailLogs(t time.Time) ([]*MailLog, error) {
ms := []*MailLog{}
err := db.Where("send_date <= ? AND processing = ?", t, false).
Find(&ms).Error
if err != nil {
Logger.Println(err)
}
return ms, err
}
// GetMailLogsByCampaign returns all of the mail logs for a given campaign.
func GetMailLogsByCampaign(cid int64) ([]*MailLog, error) {
ms := []*MailLog{}
err := db.Where("campaign_id = ?", cid).Find(&ms).Error
return ms, err
}
// LockMailLogs locks or unlocks a slice of maillogs for processing.
func LockMailLogs(ms []*MailLog, lock bool) error {
tx := db.Begin()
for i := range ms {
ms[i].Processing = lock
err := tx.Debug().Save(ms[i]).Error
if err != nil {
tx.Rollback()
return err
}
}
tx.Commit()
return nil
}
// UnlockAllMailLogs removes the processing lock for all maillogs
// in the database. This is intended to be called when Gophish is started
// so that any previously locked maillogs can resume processing.
func UnlockAllMailLogs() error {
err = db.Model(&MailLog{}).Update("processing", false).Error
return err
}

240
models/maillog_test.go Normal file
View File

@ -0,0 +1,240 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/textproto"
"time"
"github.com/gophish/gomail"
"github.com/jordan-wright/email"
"gopkg.in/check.v1"
)
func (s *ModelsSuite) TestGetQueuedMailLogs(ch *check.C) {
campaign := s.createCampaign(ch)
ms, err := GetQueuedMailLogs(campaign.LaunchDate)
ch.Assert(err, check.Equals, nil)
got := make(map[string]*MailLog)
for _, m := range ms {
got[m.RId] = m
}
for _, r := range campaign.Results {
if m, ok := got[r.RId]; ok {
ch.Assert(m.RId, check.Equals, r.RId)
ch.Assert(m.CampaignId, check.Equals, campaign.Id)
ch.Assert(m.SendDate, check.Equals, campaign.LaunchDate)
ch.Assert(m.UserId, check.Equals, campaign.UserId)
ch.Assert(m.SendAttempt, check.Equals, 0)
} else {
ch.Fatalf("Result not found in maillogs: %s", r.RId)
}
}
}
func (s *ModelsSuite) TestMailLogBackoff(ch *check.C) {
campaign := s.createCampaign(ch)
result := campaign.Results[0]
m := &MailLog{}
err := db.Where("r_id=? AND campaign_id=?", result.RId, campaign.Id).
Find(m).Error
ch.Assert(err, check.Equals, nil)
ch.Assert(m.SendAttempt, check.Equals, 0)
ch.Assert(m.SendDate, check.Equals, campaign.LaunchDate)
expectedError := &textproto.Error{
Code: 500,
Msg: "Recipient not found",
}
for i := m.SendAttempt; i < MaxSendAttempts; i++ {
err = m.Lock()
ch.Assert(err, check.Equals, nil)
ch.Assert(m.Processing, check.Equals, true)
expectedDuration := math.Pow(2, float64(m.SendAttempt+1))
expectedSendDate := m.SendDate.Add(time.Minute * time.Duration(expectedDuration))
err = m.Backoff(expectedError)
ch.Assert(err, check.Equals, nil)
ch.Assert(m.SendDate, check.Equals, expectedSendDate)
ch.Assert(m.Processing, check.Equals, false)
result, err := GetResult(m.RId)
ch.Assert(err, check.Equals, nil)
ch.Assert(result.SendDate, check.Equals, expectedSendDate)
ch.Assert(result.Status, check.Equals, STATUS_RETRY)
}
// Get our updated campaign and check for the added event
campaign, err = GetCampaign(campaign.Id, int64(1))
ch.Assert(err, check.Equals, nil)
// We expect MaxSendAttempts + the initial campaign created event
ch.Assert(len(campaign.Events), check.Equals, MaxSendAttempts+1)
// Check that we receive our error after meeting the maximum send attempts
err = m.Backoff(expectedError)
ch.Assert(err, check.Equals, ErrMaxSendAttempts)
}
func (s *ModelsSuite) TestMailLogError(ch *check.C) {
campaign := s.createCampaign(ch)
result := campaign.Results[0]
m := &MailLog{}
err := db.Where("r_id=? AND campaign_id=?", result.RId, campaign.Id).
Find(m).Error
ch.Assert(err, check.Equals, nil)
ch.Assert(m.RId, check.Equals, result.RId)
expectedError := &textproto.Error{
Code: 500,
Msg: "Recipient not found",
}
err = m.Error(expectedError)
ch.Assert(err, check.Equals, nil)
// Get our result and make sure the status is set correctly
result, err = GetResult(result.RId)
ch.Assert(err, check.Equals, nil)
ch.Assert(result.Status, check.Equals, ERROR)
// Get our updated campaign and check for the added event
campaign, err = GetCampaign(campaign.Id, int64(1))
ch.Assert(err, check.Equals, nil)
expectedEventLength := 2
ch.Assert(len(campaign.Events), check.Equals, expectedEventLength)
gotEvent := campaign.Events[1]
es := struct {
Error string `json:"error"`
}{
Error: expectedError.Error(),
}
ej, _ := json.Marshal(es)
expectedEvent := Event{
Id: gotEvent.Id,
Email: result.Email,
Message: EVENT_SENDING_ERROR,
CampaignId: campaign.Id,
Details: string(ej),
Time: gotEvent.Time,
}
ch.Assert(gotEvent, check.DeepEquals, expectedEvent)
ms, err := GetMailLogsByCampaign(campaign.Id)
ch.Assert(err, check.Equals, nil)
ch.Assert(len(ms), check.Equals, len(campaign.Results)-1)
}
func (s *ModelsSuite) TestMailLogSuccess(ch *check.C) {
campaign := s.createCampaign(ch)
result := campaign.Results[0]
m := &MailLog{}
err := db.Where("r_id=? AND campaign_id=?", result.RId, campaign.Id).
Find(m).Error
ch.Assert(err, check.Equals, nil)
ch.Assert(m.RId, check.Equals, result.RId)
err = m.Success()
ch.Assert(err, check.Equals, nil)
// Get our result and make sure the status is set correctly
result, err = GetResult(result.RId)
ch.Assert(err, check.Equals, nil)
ch.Assert(result.Status, check.Equals, EVENT_SENT)
// Get our updated campaign and check for the added event
campaign, err = GetCampaign(campaign.Id, int64(1))
ch.Assert(err, check.Equals, nil)
expectedEventLength := 2
ch.Assert(len(campaign.Events), check.Equals, expectedEventLength)
gotEvent := campaign.Events[1]
expectedEvent := Event{
Id: gotEvent.Id,
Email: result.Email,
Message: EVENT_SENT,
CampaignId: campaign.Id,
Time: gotEvent.Time,
}
ch.Assert(gotEvent, check.DeepEquals, expectedEvent)
ms, err := GetMailLogsByCampaign(campaign.Id)
ch.Assert(err, check.Equals, nil)
ch.Assert(len(ms), check.Equals, len(campaign.Results)-1)
}
func (s *ModelsSuite) TestGenerateMailLog(ch *check.C) {
campaign := Campaign{
Id: 1,
UserId: 1,
LaunchDate: time.Now().UTC(),
}
result := Result{
RId: "abc1234",
}
err := GenerateMailLog(&campaign, &result)
ch.Assert(err, check.Equals, nil)
m := MailLog{}
err = db.Where("r_id=?", result.RId).Find(&m).Error
ch.Assert(err, check.Equals, nil)
ch.Assert(m.RId, check.Equals, result.RId)
ch.Assert(m.CampaignId, check.Equals, campaign.Id)
ch.Assert(m.SendDate, check.Equals, campaign.LaunchDate)
ch.Assert(m.UserId, check.Equals, campaign.UserId)
ch.Assert(m.SendAttempt, check.Equals, 0)
ch.Assert(m.Processing, check.Equals, false)
}
func (s *ModelsSuite) TestMailLogGenerate(ch *check.C) {
campaign := s.createCampaign(ch)
result := campaign.Results[0]
m := &MailLog{}
err := db.Where("r_id=? AND campaign_id=?", result.RId, campaign.Id).
Find(m).Error
ch.Assert(err, check.Equals, nil)
msg := gomail.NewMessage()
err = m.Generate(msg)
ch.Assert(err, check.Equals, nil)
expected := &email.Email{
Subject: fmt.Sprintf("%s - Subject", result.RId),
Text: []byte(fmt.Sprintf("%s - Text", result.RId)),
HTML: []byte(fmt.Sprintf("%s - HTML", result.RId)),
}
msgBuff := &bytes.Buffer{}
_, err = msg.WriteTo(msgBuff)
ch.Assert(err, check.Equals, nil)
got, err := email.NewEmailFromReader(msgBuff)
ch.Assert(err, check.Equals, nil)
ch.Assert(got.Subject, check.Equals, expected.Subject)
ch.Assert(string(got.Text), check.Equals, string(expected.Text))
ch.Assert(string(got.HTML), check.Equals, string(expected.HTML))
}
func (s *ModelsSuite) TestUnlockAllMailLogs(ch *check.C) {
campaign := s.createCampaign(ch)
ms, err := GetMailLogsByCampaign(campaign.Id)
ch.Assert(err, check.Equals, nil)
for _, m := range ms {
ch.Assert(m.Processing, check.Equals, false)
}
err = LockMailLogs(ms, true)
ms, err = GetMailLogsByCampaign(campaign.Id)
ch.Assert(err, check.Equals, nil)
for _, m := range ms {
ch.Assert(m.Processing, check.Equals, true)
}
err = UnlockAllMailLogs()
ch.Assert(err, check.Equals, nil)
ms, err = GetMailLogsByCampaign(campaign.Id)
ch.Assert(err, check.Equals, nil)
for _, m := range ms {
ch.Assert(m.Processing, check.Equals, false)
}
}

View File

@ -36,9 +36,13 @@ const (
EVENT_OPENED string = "Email Opened" EVENT_OPENED string = "Email Opened"
EVENT_CLICKED string = "Clicked Link" EVENT_CLICKED string = "Clicked Link"
EVENT_DATA_SUBMIT string = "Submitted Data" EVENT_DATA_SUBMIT string = "Submitted Data"
EVENT_PROXY_REQUEST string = "Proxied request"
STATUS_SUCCESS string = "Success" STATUS_SUCCESS string = "Success"
STATUS_QUEUED string = "Queued"
STATUS_SENDING string = "Sending" STATUS_SENDING string = "Sending"
STATUS_UNKNOWN string = "Unknown" STATUS_UNKNOWN string = "Unknown"
STATUS_SCHEDULED string = "Scheduled"
STATUS_RETRY string = "Retrying"
ERROR string = "Error" ERROR string = "Error"
) )

View File

@ -1,10 +1,12 @@
package models package models
import ( import (
"fmt"
"net/mail" "net/mail"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/gophish/gophish/config" "github.com/gophish/gophish/config"
@ -37,12 +39,61 @@ func (s *ModelsSuite) TearDownTest(c *check.C) {
db.Delete(GroupTarget{}) db.Delete(GroupTarget{})
db.Delete(SMTP{}) db.Delete(SMTP{})
db.Delete(Page{}) db.Delete(Page{})
db.Delete(Result{})
db.Delete(MailLog{})
db.Delete(Campaign{})
// Reset users table to default state. // Reset users table to default state.
db.Not("id", 1).Delete(User{}) db.Not("id", 1).Delete(User{})
db.Model(User{}).Update("username", "admin") db.Model(User{}).Update("username", "admin")
} }
func (s *ModelsSuite) createCampaignDependencies(ch *check.C) Campaign {
group := Group{Name: "Test Group"}
group.Targets = []Target{
Target{Email: "test1@example.com", FirstName: "First", LastName: "Example"},
Target{Email: "test2@example.com", FirstName: "Second", LastName: "Example"},
}
group.UserId = 1
ch.Assert(PostGroup(&group), check.Equals, nil)
// Add a template
t := Template{Name: "Test Template"}
t.Subject = "{{.RId}} - Subject"
t.Text = "{{.RId}} - Text"
t.HTML = "{{.RId}} - HTML"
t.UserId = 1
ch.Assert(PostTemplate(&t), check.Equals, nil)
// Add a landing page
p := Page{Name: "Test Page"}
p.HTML = "<html>Test</html>"
p.UserId = 1
ch.Assert(PostPage(&p), check.Equals, nil)
// Add a sending profile
smtp := SMTP{Name: "Test Page"}
smtp.UserId = 1
smtp.Host = "example.com"
smtp.FromAddress = "test@test.com"
ch.Assert(PostSMTP(&smtp), check.Equals, nil)
c := Campaign{Name: "Test campaign"}
c.UserId = 1
c.Template = t
c.Page = p
c.SMTP = smtp
c.Groups = []Group{group}
return c
}
func (s *ModelsSuite) createCampaign(ch *check.C) Campaign {
c := s.createCampaignDependencies(ch)
// Setup and "launch" our campaign
ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil)
return c
}
func (s *ModelsSuite) TestGetUser(c *check.C) { func (s *ModelsSuite) TestGetUser(c *check.C) {
u, err := GetUser(1) u, err := GetUser(1)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
@ -123,14 +174,15 @@ func (s *ModelsSuite) TestGetGroupsNoGroups(c *check.C) {
func (s *ModelsSuite) TestGetGroup(c *check.C) { func (s *ModelsSuite) TestGetGroup(c *check.C) {
// Add group. // Add group.
PostGroup(&Group{ originalGroup := &Group{
Name: "Test Group", Name: "Test Group",
Targets: []Target{Target{Email: "test@example.com"}}, Targets: []Target{Target{Email: "test@example.com"}},
UserId: 1, UserId: 1,
}) }
c.Assert(PostGroup(originalGroup), check.Equals, nil)
// Get group and test result. // Get group and test result.
group, err := GetGroup(1, 1) group, err := GetGroup(originalGroup.Id, 1)
c.Assert(err, check.Equals, nil) c.Assert(err, check.Equals, nil)
c.Assert(len(group.Targets), check.Equals, 1) c.Assert(len(group.Targets), check.Equals, 1)
c.Assert(group.Name, check.Equals, "Test Group") c.Assert(group.Name, check.Equals, "Test Group")
@ -367,3 +419,25 @@ func (s *ModelsSuite) TestFormatAddress(c *check.C) {
} }
c.Assert(r.FormatAddress(), check.Equals, r.Email) c.Assert(r.FormatAddress(), check.Equals, r.Email)
} }
func (s *ModelsSuite) TestResultSendingStatus(ch *check.C) {
c := s.createCampaignDependencies(ch)
ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil)
// This campaign wasn't scheduled, so we expect the status to
// be sending
fmt.Println("Campaign STATUS")
fmt.Println(c.Status)
for _, r := range c.Results {
ch.Assert(r.Status, check.Equals, STATUS_SENDING)
}
}
func (s *ModelsSuite) TestResultScheduledStatus(ch *check.C) {
c := s.createCampaignDependencies(ch)
c.LaunchDate = time.Now().UTC().Add(time.Hour * time.Duration(1))
ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil)
// This campaign wasn't scheduled, so we expect the status to
// be sending
for _, r := range c.Results {
ch.Assert(r.Status, check.Equals, STATUS_SCHEDULED)
}
}

View File

@ -7,6 +7,7 @@ import (
"math/big" "math/big"
"net" "net"
"net/mail" "net/mail"
"time"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/oschwald/maxminddb-golang" "github.com/oschwald/maxminddb-golang"
@ -24,18 +25,19 @@ type mmGeoPoint struct {
// Result contains the fields for a result object, // Result contains the fields for a result object,
// which is a representation of a target in a campaign. // which is a representation of a target in a campaign.
type Result struct { type Result struct {
Id int64 `json:"-"` Id int64 `json:"-"`
CampaignId int64 `json:"-"` CampaignId int64 `json:"-"`
UserId int64 `json:"-"` UserId int64 `json:"-"`
RId string `json:"id"` RId string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name"` LastName string `json:"last_name"`
Position string `json:"position"` Position string `json:"position"`
Status string `json:"status" sql:"not null"` Status string `json:"status" sql:"not null"`
IP string `json:"ip"` IP string `json:"ip"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
SendDate time.Time `json:"send_date"`
} }
// UpdateStatus updates the status of the result in the database // UpdateStatus updates the status of the result in the database

View File

@ -1,15 +1,32 @@
package models package models
import ( import (
"crypto/tls"
"errors" "errors"
"net/mail" "net/mail"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/gophish/gomail"
"github.com/gophish/gophish/mailer"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
) )
// Dialer is a wrapper around a standard gomail.Dialer in order
// to implement the mailer.Dialer interface. This allows us to better
// separate the mailer package as opposed to forcing a connection
// between mailer and gomail.
type Dialer struct {
*gomail.Dialer
}
// Dial wraps the gomail dialer's Dial command
func (d *Dialer) Dial() (mailer.Sender, error) {
return d.Dialer.Dial()
}
// SMTP contains the attributes needed to handle the sending of campaign emails // SMTP contains the attributes needed to handle the sending of campaign emails
type SMTP struct { type SMTP struct {
Id int64 `json:"id" gorm:"column:id; primary_key:yes"` Id int64 `json:"id" gorm:"column:id; primary_key:yes"`
@ -76,6 +93,34 @@ func (s *SMTP) Validate() error {
return err return err
} }
// GetDialer returns a dialer for the given SMTP profile
func (s *SMTP) GetDialer() (mailer.Dialer, error) {
// Setup the message and dial
hp := strings.Split(s.Host, ":")
if len(hp) < 2 {
hp = append(hp, "25")
}
// Any issues should have been caught in validation, but we'll
// double check here.
port, err := strconv.Atoi(hp[1])
if err != nil {
Logger.Println(err)
return nil, err
}
d := gomail.NewDialer(hp[0], port, s.Username, s.Password)
d.TLSConfig = &tls.Config{
ServerName: s.Host,
InsecureSkipVerify: s.IgnoreCertErrors,
}
hostname, err := os.Hostname()
if err != nil {
Logger.Println(err)
hostname = "localhost"
}
d.LocalName = hostname
return &Dialer{d}, err
}
// GetSMTPs returns the SMTPs owned by the given user. // GetSMTPs returns the SMTPs owned by the given user.
func GetSMTPs(uid int64) ([]SMTP, error) { func GetSMTPs(uid int64) ([]SMTP, error) {
ss := []SMTP{} ss := []SMTP{}
@ -84,7 +129,7 @@ func GetSMTPs(uid int64) ([]SMTP, error) {
Logger.Println(err) Logger.Println(err)
return ss, err return ss, err
} }
for i, _ := range ss { for i := range ss {
err = db.Where("smtp_id=?", ss[i].Id).Find(&ss[i].Headers).Error err = db.Where("smtp_id=?", ss[i].Id).Find(&ss[i].Headers).Error
if err != nil && err != gorm.ErrRecordNotFound { if err != nil && err != gorm.ErrRecordNotFound {
Logger.Println(err) Logger.Println(err)
@ -137,7 +182,7 @@ func PostSMTP(s *SMTP) error {
Logger.Println(err) Logger.Println(err)
} }
// Save custom headers // Save custom headers
for i, _ := range s.Headers { for i := range s.Headers {
s.Headers[i].SMTPId = s.Id s.Headers[i].SMTPId = s.Id
err := db.Save(&s.Headers[i]).Error err := db.Save(&s.Headers[i]).Error
if err != nil { if err != nil {
@ -166,7 +211,7 @@ func PutSMTP(s *SMTP) error {
Logger.Println(err) Logger.Println(err)
return err return err
} }
for i, _ := range s.Headers { for i := range s.Headers {
s.Headers[i].SMTPId = s.Id s.Headers[i].SMTPId = s.Id
err := db.Save(&s.Headers[i]).Error err := db.Save(&s.Headers[i]).Error
if err != nil { if err != nil {

24
models/smtp_test.go Normal file
View File

@ -0,0 +1,24 @@
package models
import (
"fmt"
check "gopkg.in/check.v1"
)
func (s *ModelsSuite) TestSMTPGetDialer(ch *check.C) {
host := "localhost"
port := 25
smtp := SMTP{
Host: fmt.Sprintf("%s:%d", host, port),
IgnoreCertErrors: false,
}
d, err := smtp.GetDialer()
ch.Assert(err, check.Equals, nil)
dialer := d.(*Dialer).Dialer
ch.Assert(dialer.Host, check.Equals, host)
ch.Assert(dialer.Port, check.Equals, port)
ch.Assert(dialer.TLSConfig.ServerName, check.Equals, smtp.Host)
ch.Assert(dialer.TLSConfig.InsecureSkipVerify, check.Equals, smtp.IgnoreCertErrors)
}

File diff suppressed because one or more lines are too long

View File

@ -72,6 +72,18 @@ var statuses = {
icon: "fa-spinner", icon: "fa-spinner",
point: "ct-point-sending" point: "ct-point-sending"
}, },
"Retrying": {
color: "#6c7a89",
label: "label-default",
icon: "fa-clock-o",
point: "ct-point-error"
},
"Scheduled": {
color: "#428bca",
label: "label-primary",
icon: "fa-clock-o",
point: "ct-point-sending"
},
"Campaign Created": { "Campaign Created": {
label: "label-success", label: "label-success",
icon: "fa-rocket" icon: "fa-rocket"
@ -268,7 +280,9 @@ function renderTimeline(data) {
"first_name": data[2], "first_name": data[2],
"last_name": data[3], "last_name": data[3],
"email": data[4], "email": data[4],
"position": data[5] "position": data[5],
"status": data[6],
"send_date": data[7]
} }
results = '<div class="timeline col-sm-12 well well-lg">' + results = '<div class="timeline col-sm-12 well well-lg">' +
'<h6>Timeline for ' + escapeHtml(record.first_name) + ' ' + escapeHtml(record.last_name) + '<h6>Timeline for ' + escapeHtml(record.first_name) + ' ' + escapeHtml(record.last_name) +
@ -317,6 +331,15 @@ function renderTimeline(data) {
results += '</div></div>' results += '</div></div>'
} }
}) })
// Add the scheduled send event at the bottom
if (record.status == "Scheduled" || record.status == "Retrying") {
results += '<div class="timeline-entry">' +
' <div class="timeline-bar"></div>'
results +=
' <div class="timeline-icon ' + statuses[record.status].label + '">' +
' <i class="fa ' + statuses[record.status].icon + '"></i></div>' +
' <div class="timeline-message">' + "Scheduled to send at " + record.send_date + '</span>'
}
results += '</div></div>' results += '</div></div>'
return results return results
} }
@ -485,6 +508,22 @@ var updateMap = function (results) {
map.bubbles(bubbles) map.bubbles(bubbles)
} }
/**
* Creates a status label for use in the results datatable
* @param {string} status
* @param {moment(datetime)} send_date
*/
function createStatusLabel(status, send_date) {
var label = statuses[status].label || "label-default";
var statusColumn = "<span class=\"label " + label + "\">" + status + "</span>"
// Add the tooltip if the email is scheduled to be sent
if (status == "Scheduled" || status == "Retrying") {
var sendDateMessage = "Scheduled to send at " + send_date
statusColumn = "<span class=\"label " + label + "\" data-toggle=\"tooltip\" data-placement=\"top\" data-html=\"true\" title=\"" + sendDateMessage + "\">" + status + "</span>"
}
return statusColumn
}
/* poll - Queries the API and updates the UI with the results /* poll - Queries the API and updates the UI with the results
* *
* Updates: * Updates:
@ -564,10 +603,12 @@ function poll() {
var rid = rowData[0] var rid = rowData[0]
$.each(campaign.results, function (j, result) { $.each(campaign.results, function (j, result) {
if (result.id == rid) { if (result.id == rid) {
var label = statuses[result.status].label || "label-default"; rowData[7] = moment(result.send_date).format('MMMM Do YYYY, h:mm:ss a')
rowData[6] = "<span class=\"label " + label + "\">" + result.status + "</span>" rowData[6] = result.status
resultsTable.row(i).data(rowData) resultsTable.row(i).data(rowData)
if (row.child.isShown()) { if (row.child.isShown()) {
$(row.node()).find("i").removeClass("fa-caret-right")
$(row.node()).find("i").addClass("fa-caret-down")
row.child(renderTimeline(row.data())) row.child(renderTimeline(row.data()))
} }
return false return false
@ -577,6 +618,7 @@ function poll() {
resultsTable.draw(false) resultsTable.draw(false)
/* Update the map information */ /* Update the map information */
updateMap(campaign.results) updateMap(campaign.results)
$('[data-toggle="tooltip"]').tooltip()
$("#refresh_message").hide() $("#refresh_message").hide()
$("#refresh_btn").show() $("#refresh_btn").show()
}) })
@ -599,8 +641,6 @@ function load() {
$('#complete_button').text('Completed!'); $('#complete_button').text('Completed!');
doPoll = false; doPoll = false;
} }
// Setup tooltips
$('[data-toggle="tooltip"]').tooltip()
// Setup viewing the details of a result // Setup viewing the details of a result
$("#resultsTable").on("click", ".timeline-event-details", function () { $("#resultsTable").on("click", ".timeline-event-details", function () {
// Show the parameters // Show the parameters
@ -622,15 +662,22 @@ function load() {
[2, "asc"] [2, "asc"]
], ],
columnDefs: [{ columnDefs: [{
orderable: false, orderable: false,
targets: "no-sort" targets: "no-sort"
}, { }, {
className: "details-control", className: "details-control",
"targets": [1] "targets": [1]
}, { }, {
"visible": false, "visible": false,
"targets": [0] "targets": [0, 7]
}] },
{
"render": function (data, type, row) {
return createStatusLabel(data, row[7])
},
"targets": [6]
}
]
}); });
resultsTable.clear(); resultsTable.clear();
var email_series_data = {} var email_series_data = {}
@ -639,7 +686,6 @@ function load() {
email_series_data[k] = 0 email_series_data[k] = 0
}); });
$.each(campaign.results, function (i, result) { $.each(campaign.results, function (i, result) {
label = statuses[result.status].label || "label-default";
resultsTable.row.add([ resultsTable.row.add([
result.id, result.id,
"<i class=\"fa fa-caret-right\"></i>", "<i class=\"fa fa-caret-right\"></i>",
@ -647,7 +693,8 @@ function load() {
escapeHtml(result.last_name) || "", escapeHtml(result.last_name) || "",
escapeHtml(result.email) || "", escapeHtml(result.email) || "",
escapeHtml(result.position) || "", escapeHtml(result.position) || "",
"<span class=\"label " + label + "\">" + result.status + "</span>" result.status,
moment(result.send_date).format('MMMM Do YYYY, h:mm:ss a')
]) ])
email_series_data[result.status]++; email_series_data[result.status]++;
// Backfill status values // Backfill status values
@ -657,6 +704,8 @@ function load() {
} }
}) })
resultsTable.draw(); resultsTable.draw();
// Setup tooltips
$('[data-toggle="tooltip"]').tooltip()
// Setup the individual timelines // Setup the individual timelines
$('#resultsTable tbody').on('click', 'td.details-control', function () { $('#resultsTable tbody').on('click', 'td.details-control', function () {
var tr = $(this).closest('tr'); var tr = $(this).closest('tr');
@ -667,14 +716,12 @@ function load() {
tr.removeClass('shown'); tr.removeClass('shown');
$(this).find("i").removeClass("fa-caret-down") $(this).find("i").removeClass("fa-caret-down")
$(this).find("i").addClass("fa-caret-right") $(this).find("i").addClass("fa-caret-right")
row.invalidate('dom').draw(false)
} else { } else {
// Open this row // Open this row
$(this).find("i").removeClass("fa-caret-right") $(this).find("i").removeClass("fa-caret-right")
$(this).find("i").addClass("fa-caret-down") $(this).find("i").addClass("fa-caret-down")
row.child(renderTimeline(row.data())).show(); row.child(renderTimeline(row.data())).show();
tr.addClass('shown'); tr.addClass('shown');
row.invalidate('dom').draw(false)
} }
}); });
// Setup the graphs // Setup the graphs

View File

@ -25,4 +25,8 @@ THE SOFTWARE.
*/ */
// Package worker contains the functionality for the background worker process. // Package worker contains the functionality for the background worker process.
// It starts a background service that polls every minute for scheduled campaigns
// to be launched.
// If a campaign is found, it gathers the maillogs associated with the campaign and
// sends them to the mailer package to be processed.
package worker package worker

View File

@ -1,23 +1,12 @@
package worker package worker
import ( import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log" "log"
"net/mail"
"os" "os"
"strconv"
"strings"
"text/template"
"time" "time"
"github.com/gophish/gophish/mailer"
"github.com/gophish/gophish/models" "github.com/gophish/gophish/models"
"gopkg.in/gomail.v2"
) )
// Logger is the logger for the worker // Logger is the logger for the worker
@ -31,343 +20,84 @@ func New() *Worker {
return &Worker{} return &Worker{}
} }
// Start launches the worker to poll the database every minute for any jobs. // Start launches the worker to poll the database every minute for any pending maillogs
// If a job is found, it launches the job // that need to be processed.
func (w *Worker) Start() { func (w *Worker) Start() {
Logger.Println("Background Worker Started Successfully - Waiting for Campaigns") Logger.Println("Background Worker Started Successfully - Waiting for Campaigns")
for t := range time.Tick(1 * time.Minute) { for t := range time.Tick(1 * time.Minute) {
cs, err := models.GetQueuedCampaigns(t.UTC()) ms, err := models.GetQueuedMailLogs(t.UTC())
// Not really sure of a clean way to catch errors per campaign...
if err != nil { if err != nil {
Logger.Println(err) Logger.Println(err)
continue continue
} }
for _, c := range cs { // Lock the MailLogs (they will be unlocked after processing)
go func(c models.Campaign) { err = models.LockMailLogs(ms, true)
processCampaign(&c) if err != nil {
}(c) Logger.Println(err)
continue
}
// We'll group the maillogs by campaign ID to (sort of) group
// them by sending profile. This lets the mailer re-use the Sender
// instead of having to re-connect to the SMTP server for every
// email.
msg := make(map[int64][]mailer.Mail)
for _, m := range ms {
msg[m.CampaignId] = append(msg[m.CampaignId], m)
}
// Next, we process each group of maillogs in parallel
for cid, msc := range msg {
go func(cid int64, msc []mailer.Mail) {
uid := msc[0].(*models.MailLog).UserId
c, err := models.GetCampaign(cid, uid)
if err != nil {
Logger.Println(err)
errorMail(err, msc)
return
}
if c.Status == models.CAMPAIGN_QUEUED {
err := c.UpdateStatus(models.CAMPAIGN_IN_PROGRESS)
if err != nil {
Logger.Println(err)
return
}
}
Logger.Printf("Sending %d maillogs to Mailer", len(msc))
mailer.Mailer.Queue <- msc
}(cid, msc)
} }
} }
} }
func processCampaign(c *models.Campaign) { // LaunchCampaign starts a campaign
Logger.Printf("Worker received: %s", c.Name) func (w *Worker) LaunchCampaign(c models.Campaign) {
err := c.UpdateStatus(models.CAMPAIGN_IN_PROGRESS) ms, err := models.GetMailLogsByCampaign(c.Id)
if err != nil { if err != nil {
Logger.Println(err) Logger.Println(err)
}
f, err := mail.ParseAddress(c.SMTP.FromAddress)
if err != nil {
Logger.Println(err)
}
fn := f.Name
if fn == "" {
fn = f.Address
}
// Setup the message and dial
hp := strings.Split(c.SMTP.Host, ":")
if len(hp) < 2 {
hp = append(hp, "25")
}
// Any issues should have been caught in validation, so we just log
port, err := strconv.Atoi(hp[1])
if err != nil {
Logger.Println(err)
}
d := gomail.NewDialer(hp[0], port, c.SMTP.Username, c.SMTP.Password)
d.TLSConfig = &tls.Config{
ServerName: c.SMTP.Host,
InsecureSkipVerify: c.SMTP.IgnoreCertErrors,
}
hostname, err := os.Hostname()
if err != nil {
Logger.Println(err)
hostname = "localhost"
}
d.LocalName = hostname
s, err := d.Dial()
// Short circuit if we have an err
// However, we still need to update each target
if err != nil {
Logger.Println(err)
for _, t := range c.Results {
es := struct {
Error string `json:"error"`
}{
Error: err.Error(),
}
ej, err := json.Marshal(es)
if err != nil {
Logger.Println(err)
}
err = t.UpdateStatus(models.ERROR)
if err != nil {
Logger.Println(err)
}
err = c.AddEvent(models.Event{Email: t.Email, Message: models.EVENT_SENDING_ERROR, Details: string(ej)})
if err != nil {
Logger.Println(err)
}
}
return return
} }
// Send each email models.LockMailLogs(ms, true)
e := gomail.NewMessage() // This is required since you cannot pass a slice of values
for _, t := range c.Results { // that implements an interface as a slice of that interface.
e.SetAddressHeader("From", f.Address, f.Name) mailEntries := []mailer.Mail{}
td := struct { for _, m := range ms {
models.Result mailEntries = append(mailEntries, m)
URL string
TrackingURL string
Tracker string
From string
}{
t,
c.URL + "?rid=" + t.RId,
c.URL + "/track?rid=" + t.RId,
"<img alt='' style='display: none' src='" + c.URL + "/track?rid=" + t.RId + "'/>",
fn,
}
// Parse the customHeader templates
for _, header := range c.SMTP.Headers {
parsedHeader := struct {
Key bytes.Buffer
Value bytes.Buffer
}{}
keytmpl, err := template.New("text_template").Parse(header.Key)
if err != nil {
Logger.Println(err)
}
err = keytmpl.Execute(&parsedHeader.Key, td)
if err != nil {
Logger.Println(err)
}
valtmpl, err := template.New("text_template").Parse(header.Value)
if err != nil {
Logger.Println(err)
}
err = valtmpl.Execute(&parsedHeader.Value, td)
if err != nil {
Logger.Println(err)
}
// Add our header immediately
e.SetHeader(parsedHeader.Key.String(), parsedHeader.Value.String())
}
// Parse remaining templates
var subjBuff bytes.Buffer
tmpl, err := template.New("text_template").Parse(c.Template.Subject)
if err != nil {
Logger.Println(err)
}
err = tmpl.Execute(&subjBuff, td)
if err != nil {
Logger.Println(err)
}
e.SetHeader("Subject", subjBuff.String())
Logger.Println("Creating email using template")
e.SetHeader("To", t.FormatAddress())
if c.Template.Text != "" {
var textBuff bytes.Buffer
tmpl, err = template.New("text_template").Parse(c.Template.Text)
if err != nil {
Logger.Println(err)
}
err = tmpl.Execute(&textBuff, td)
if err != nil {
Logger.Println(err)
}
e.SetBody("text/plain", textBuff.String())
}
if c.Template.HTML != "" {
var htmlBuff bytes.Buffer
tmpl, err = template.New("html_template").Parse(c.Template.HTML)
if err != nil {
Logger.Println(err)
}
err = tmpl.Execute(&htmlBuff, td)
if err != nil {
Logger.Println(err)
}
if c.Template.Text == "" {
e.SetBody("text/html", htmlBuff.String())
} else {
e.AddAlternative("text/html", htmlBuff.String())
}
}
// Attach the files
for _, a := range c.Template.Attachments {
e.Attach(func(a models.Attachment) (string, gomail.FileSetting, gomail.FileSetting) {
h := map[string][]string{"Content-ID": {fmt.Sprintf("<%s>", a.Name)}}
return a.Name, gomail.SetCopyFunc(func(w io.Writer) error {
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
_, err = io.Copy(w, decoder)
return err
}), gomail.SetHeader(h)
}(a))
}
Logger.Printf("Sending Email to %s\n", t.Email)
err = gomail.Send(s, e)
if err != nil {
Logger.Println(err)
es := struct {
Error string `json:"error"`
}{
Error: err.Error(),
}
ej, err := json.Marshal(es)
if err != nil {
Logger.Println(err)
}
err = t.UpdateStatus(models.ERROR)
if err != nil {
Logger.Println(err)
}
err = c.AddEvent(models.Event{Email: t.Email, Message: models.EVENT_SENDING_ERROR, Details: string(ej)})
if err != nil {
Logger.Println(err)
}
} else {
err = t.UpdateStatus(models.EVENT_SENT)
if err != nil {
Logger.Println(err)
}
err = c.AddEvent(models.Event{Email: t.Email, Message: models.EVENT_SENT})
if err != nil {
Logger.Println(err)
}
}
e.Reset()
}
err = c.UpdateStatus(models.CAMPAIGN_EMAILS_SENT)
if err != nil {
Logger.Println(err)
} }
mailer.Mailer.Queue <- mailEntries
} }
func SendTestEmail(s *models.SendTestEmailRequest) error { // SendTestEmail sends a test email
f, err := mail.ParseAddress(s.SMTP.FromAddress) func (w *Worker) SendTestEmail(s *models.SendTestEmailRequest) error {
if err != nil { go func() {
Logger.Println(err) mailer.Mailer.Queue <- []mailer.Mail{s}
return err }()
} return <-s.ErrorChan
hp := strings.Split(s.SMTP.Host, ":") }
if len(hp) < 2 {
hp = append(hp, "25") // errorMail is a helper to handle erroring out a slice of Mail instances
} // in the case that an unrecoverable error occurs.
port, err := strconv.Atoi(hp[1]) func errorMail(err error, ms []mailer.Mail) {
if err != nil { for _, m := range ms {
Logger.Println(err) m.Error(err)
return err }
}
d := gomail.NewDialer(hp[0], port, s.SMTP.Username, s.SMTP.Password)
d.TLSConfig = &tls.Config{
ServerName: s.SMTP.Host,
InsecureSkipVerify: s.SMTP.IgnoreCertErrors,
}
hostname, err := os.Hostname()
if err != nil {
Logger.Println(err)
hostname = "localhost"
}
d.LocalName = hostname
dc, err := d.Dial()
if err != nil {
Logger.Println(err)
return err
}
Logger.Println("Creating email using template")
e := gomail.NewMessage()
// Parse the customHeader templates
for _, header := range s.SMTP.Headers {
parsedHeader := struct {
Key bytes.Buffer
Value bytes.Buffer
}{}
keytmpl, err := template.New("text_template").Parse(header.Key)
if err != nil {
Logger.Println(err)
}
err = keytmpl.Execute(&parsedHeader.Key, s)
if err != nil {
Logger.Println(err)
}
valtmpl, err := template.New("text_template").Parse(header.Value)
if err != nil {
Logger.Println(err)
}
err = valtmpl.Execute(&parsedHeader.Value, s)
if err != nil {
Logger.Println(err)
}
// Add our header immediately
e.SetHeader(parsedHeader.Key.String(), parsedHeader.Value.String())
}
e.SetAddressHeader("From", f.Address, f.Name)
e.SetHeader("To", s.FormatAddress())
// Parse the templates
var subjBuff bytes.Buffer
tmpl, err := template.New("text_template").Parse(s.Template.Subject)
if err != nil {
Logger.Println(err)
}
err = tmpl.Execute(&subjBuff, s)
if err != nil {
Logger.Println(err)
}
e.SetHeader("Subject", subjBuff.String())
if s.Template.Text != "" {
var textBuff bytes.Buffer
tmpl, err = template.New("text_template").Parse(s.Template.Text)
if err != nil {
Logger.Println(err)
}
err = tmpl.Execute(&textBuff, s)
if err != nil {
Logger.Println(err)
}
e.SetBody("text/plain", textBuff.String())
}
if s.Template.HTML != "" {
var htmlBuff bytes.Buffer
tmpl, err = template.New("html_template").Parse(s.Template.HTML)
if err != nil {
Logger.Println(err)
}
err = tmpl.Execute(&htmlBuff, s)
if err != nil {
Logger.Println(err)
}
// If we don't have a text part, make the html the root part
if s.Template.Text == "" {
e.SetBody("text/html", htmlBuff.String())
} else {
e.AddAlternative("text/html", htmlBuff.String())
}
}
// Attach the files
for _, a := range s.Template.Attachments {
e.Attach(func(a models.Attachment) (string, gomail.FileSetting) {
return a.Name, gomail.SetCopyFunc(func(w io.Writer) error {
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(a.Content))
_, err = io.Copy(w, decoder)
return err
})
}(a))
}
Logger.Printf("Sending Email to %s\n", s.Email)
err = gomail.Send(dc, e)
if err != nil {
Logger.Println(err)
// For now, let's split the error and return
// the last element (the most descriptive error message)
serr := strings.Split(err.Error(), ":")
return errors.New(serr[len(serr)-1])
}
return err
} }

79
worker/worker_test.go Normal file
View File

@ -0,0 +1,79 @@
package worker
import (
"github.com/gophish/gophish/config"
"github.com/gophish/gophish/models"
"github.com/stretchr/testify/suite"
)
// WorkerSuite is a suite of tests to cover API related functions
type WorkerSuite struct {
suite.Suite
ApiKey string
}
func (s *WorkerSuite) SetupSuite() {
config.Conf.DBName = "sqlite3"
config.Conf.DBPath = ":memory:"
config.Conf.MigrationsPath = "../db/db_sqlite3/migrations/"
err := models.Setup()
if err != nil {
s.T().Fatalf("Failed creating database: %v", err)
}
s.Nil(err)
}
func (s *WorkerSuite) TearDownTest() {
campaigns, _ := models.GetCampaigns(1)
for _, campaign := range campaigns {
models.DeleteCampaign(campaign.Id)
}
}
func (s *WorkerSuite) SetupTest() {
config.Conf.TestFlag = true
// Add a group
group := models.Group{Name: "Test Group"}
group.Targets = []models.Target{
models.Target{Email: "test1@example.com", FirstName: "First", LastName: "Example"},
models.Target{Email: "test2@example.com", FirstName: "Second", LastName: "Example"},
}
group.UserId = 1
models.PostGroup(&group)
// Add a template
t := models.Template{Name: "Test Template"}
t.Subject = "Test subject"
t.Text = "Text text"
t.HTML = "<html>Test</html>"
t.UserId = 1
models.PostTemplate(&t)
// Add a landing page
p := models.Page{Name: "Test Page"}
p.HTML = "<html>Test</html>"
p.UserId = 1
models.PostPage(&p)
// Add a sending profile
smtp := models.SMTP{Name: "Test Page"}
smtp.UserId = 1
smtp.Host = "example.com"
smtp.FromAddress = "test@test.com"
models.PostSMTP(&smtp)
// Setup and "launch" our campaign
// Set the status such that no emails are attempted
c := models.Campaign{Name: "Test campaign"}
c.UserId = 1
c.Template = t
c.Page = p
c.SMTP = smtp
c.Groups = []models.Group{group}
models.PostCampaign(&c, c.UserId)
c.UpdateStatus(models.CAMPAIGN_EMAILS_SENT)
}
func (s *WorkerSuite) TestMailSendSuccess() {
// TODO
}