diff --git a/.travis.yml b/.travis.yml index c79c5179..99d630f9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ language: go sudo: false go: - - 1.8 - 1.9 - tip diff --git a/db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql b/db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql new file mode 100644 index 00000000..100c77a8 --- /dev/null +++ b/db/db_mysql/migrations/20180830215615_0.7.0_send_by_date.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- SQL in this section is executed when the migration is applied. +ALTER TABLE campaigns ADD COLUMN send_by_date DATETIME; + +-- +goose Down +-- SQL in this section is executed when the migration is rolled back. diff --git a/db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql b/db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql new file mode 100644 index 00000000..100c77a8 --- /dev/null +++ b/db/db_sqlite3/migrations/20180830215615_0.7.0_send_by_date.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- SQL in this section is executed when the migration is applied. +ALTER TABLE campaigns ADD COLUMN send_by_date DATETIME; + +-- +goose Down +-- SQL in this section is executed when the migration is rolled back. diff --git a/mailer/mailer.go b/mailer/mailer.go index 06a34125..4a2a7410 100644 --- a/mailer/mailer.go +++ b/mailer/mailer.go @@ -83,7 +83,6 @@ func (mw *MailWorker) Start(ctx context.Context) { return case ms := <-mw.Queue: go func(ctx context.Context, ms []Mail) { - log.Infof("Mailer got %d mail to send", len(ms)) dialer, err := ms[0].GetDialer() if err != nil { errorMail(err, ms) diff --git a/models/campaign.go b/models/campaign.go index 90d2c873..80e86b19 100644 --- a/models/campaign.go +++ b/models/campaign.go @@ -17,6 +17,7 @@ type Campaign struct { Name string `json:"name" sql:"not null"` CreatedDate time.Time `json:"created_date"` LaunchDate time.Time `json:"launch_date"` + SendByDate time.Time `json:"send_by_date"` CompletedDate time.Time `json:"completed_date"` TemplateId int64 `json:"-"` Template Template `json:"template"` @@ -52,6 +53,7 @@ type CampaignSummary struct { Id int64 `json:"id"` CreatedDate time.Time `json:"created_date"` LaunchDate time.Time `json:"launch_date"` + SendByDate time.Time `json:"send_by_date"` CompletedDate time.Time `json:"completed_date"` Status string `json:"status"` Name string `json:"name"` @@ -120,6 +122,10 @@ var ErrPageNotFound = errors.New("Page not found") // ErrSMTPNotFound indicates a sending profile specified by the user does not exist in the database var ErrSMTPNotFound = errors.New("Sending profile not found") +// ErrInvalidSendByDate indicates that the user specified a send by date that occurs before the +// launch date +var ErrInvalidSendByDate = errors.New("The launch date must be before the \"send emails by\" date") + // RecipientParameter is the URL parameter that points to the result ID for a recipient. const RecipientParameter = "rid" @@ -136,6 +142,8 @@ func (c *Campaign) Validate() error { return ErrPageNotSpecified case c.SMTP.Name == "": return ErrSMTPNotSpecified + case !c.SendByDate.IsZero() && !c.LaunchDate.IsZero() && c.SendByDate.Before(c.LaunchDate): + return ErrInvalidSendByDate } return nil } @@ -218,6 +226,27 @@ func (c *Campaign) getFromAddress() string { return c.SMTP.FromAddress } +// generateSendDate creates a sendDate +func (c *Campaign) generateSendDate(idx int, totalRecipients int) time.Time { + // If no send date is specified, just return the launch date + if c.SendByDate.IsZero() || c.SendByDate.Equal(c.LaunchDate) { + return c.LaunchDate + } + // Otherwise, we can calculate the range of minutes to send emails + // (since we only poll once per minute) + totalMinutes := c.SendByDate.Sub(c.LaunchDate).Minutes() + + // Next, we can determine how many minutes should elapse between emails + minutesPerEmail := totalMinutes / float64(totalRecipients) + + // Then, we can calculate the offset for this particular email + offset := int(minutesPerEmail * float64(idx)) + + // Finally, we can just add this offset to the launch date to determine + // when the email should be sent + return c.LaunchDate.Add(time.Duration(offset) * time.Minute) +} + // getCampaignStats returns a CampaignStats object for the campaign with the given campaign ID. // It also backfills numbers as appropriate with a running total, so that the values are aggregated. func getCampaignStats(cid int64) (CampaignStats, error) { @@ -387,10 +416,16 @@ func PostCampaign(c *Campaign, uid int64) error { } else { c.LaunchDate = c.LaunchDate.UTC() } + if !c.SendByDate.IsZero() { + c.SendByDate = c.SendByDate.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 + // Also, later we'll need to know the total number of recipients (counting + // duplicates is ok for now), so we'll do that here to save a loop. + totalRecipients := 0 for i, g := range c.Groups { c.Groups[i], err = GetGroupByName(g.Name, uid) if err == gorm.ErrRecordNotFound { @@ -402,6 +437,7 @@ func PostCampaign(c *Campaign, uid int64) error { log.Error(err) return err } + totalRecipients += len(c.Groups[i].Targets) } // Check to make sure the template exists t, err := GetTemplateByName(c.Template.Name, uid) @@ -454,6 +490,7 @@ func PostCampaign(c *Campaign, uid int64) error { } // Insert all the results resultMap := make(map[string]bool) + recipientIndex := 0 for _, g := range c.Groups { // Insert a result for each target in the group for _, t := range g.Targets { @@ -463,6 +500,7 @@ func PostCampaign(c *Campaign, uid int64) error { continue } resultMap[t.Email] = true + sendDate := c.generateSendDate(recipientIndex, totalRecipients) r := &Result{ BaseRecipient: BaseRecipient{ Email: t.Email, @@ -473,11 +511,11 @@ func PostCampaign(c *Campaign, uid int64) error { Status: STATUS_SCHEDULED, CampaignId: c.Id, UserId: c.UserId, - SendDate: c.LaunchDate, + SendDate: sendDate, Reported: false, ModifiedDate: c.CreatedDate, } - if c.Status == CAMPAIGN_IN_PROGRESS { + if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) { r.Status = STATUS_SENDING } err = r.GenerateId() @@ -492,11 +530,13 @@ func PostCampaign(c *Campaign, uid int64) error { }).Error(err) } c.Results = append(c.Results, *r) - err = GenerateMailLog(c, r) + log.Infof("Creating maillog for %s to send at %s\n", r.Email, sendDate) + err = GenerateMailLog(c, r, sendDate) if err != nil { log.Error(err) continue } + recipientIndex++ } } err = db.Save(c).Error diff --git a/models/campaign_test.go b/models/campaign_test.go new file mode 100644 index 00000000..7cb3e2d3 --- /dev/null +++ b/models/campaign_test.go @@ -0,0 +1,81 @@ +package models + +import ( + "time" + + check "gopkg.in/check.v1" +) + +func (s *ModelsSuite) TestGenerateSendDate(c *check.C) { + campaign := s.createCampaignDependencies(c) + // Test that if no launch date is provided, the campaign's creation date + // is used. + err := PostCampaign(&campaign, campaign.UserId) + c.Assert(err, check.Equals, nil) + c.Assert(campaign.LaunchDate, check.Equals, campaign.CreatedDate) + + ms, err := GetMailLogsByCampaign(campaign.Id) + c.Assert(err, check.Equals, nil) + for _, m := range ms { + c.Assert(m.SendDate, check.Equals, campaign.CreatedDate) + } + + // Test that if no send date is provided, all the emails are sent at the + // campaign's launch date + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + err = PostCampaign(&campaign, campaign.UserId) + c.Assert(err, check.Equals, nil) + + ms, err = GetMailLogsByCampaign(campaign.Id) + c.Assert(err, check.Equals, nil) + for _, m := range ms { + c.Assert(m.SendDate, check.Equals, campaign.LaunchDate) + } + + // Finally, test that if a send date is provided, the emails are staggered + // correctly. + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + campaign.SendByDate = campaign.LaunchDate.Add(2 * time.Minute) + err = PostCampaign(&campaign, campaign.UserId) + c.Assert(err, check.Equals, nil) + + ms, err = GetMailLogsByCampaign(campaign.Id) + c.Assert(err, check.Equals, nil) + sendingOffset := 2 / float64(len(ms)) + for i, m := range ms { + expectedOffset := int(sendingOffset * float64(i)) + expectedDate := campaign.LaunchDate.Add(time.Duration(expectedOffset) * time.Minute) + c.Assert(m.SendDate, check.Equals, expectedDate) + } +} + +func (s *ModelsSuite) TestCampaignDateValidation(c *check.C) { + campaign := s.createCampaignDependencies(c) + // If both are zero, then the campaign should start immediately with no + // send by date + err := campaign.Validate() + c.Assert(err, check.Equals, nil) + + // If the launch date is specified, then the send date is optional + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + err = campaign.Validate() + c.Assert(err, check.Equals, nil) + + // If the send date is greater than the launch date, then there's no + //problem + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + campaign.SendByDate = campaign.LaunchDate.Add(1 * time.Minute) + err = campaign.Validate() + c.Assert(err, check.Equals, nil) + + // If the send date is less than the launch date, then there's an issue + campaign = s.createCampaignDependencies(c) + campaign.LaunchDate = time.Now().UTC() + campaign.SendByDate = campaign.LaunchDate.Add(-1 * time.Minute) + err = campaign.Validate() + c.Assert(err, check.Equals, ErrInvalidSendByDate) +} diff --git a/models/maillog.go b/models/maillog.go index 80312902..3b56a163 100644 --- a/models/maillog.go +++ b/models/maillog.go @@ -38,12 +38,12 @@ type MailLog struct { // 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 { +func GenerateMailLog(c *Campaign, r *Result, sendDate time.Time) error { m := &MailLog{ UserId: c.UserId, CampaignId: c.Id, RId: r.RId, - SendDate: c.LaunchDate, + SendDate: sendDate, } err = db.Save(m).Error return err diff --git a/models/maillog_test.go b/models/maillog_test.go index a866cd3a..c3c73a9c 100644 --- a/models/maillog_test.go +++ b/models/maillog_test.go @@ -193,7 +193,7 @@ func (s *ModelsSuite) TestGenerateMailLog(ch *check.C) { result := Result{ RId: "abc1234", } - err := GenerateMailLog(&campaign, &result) + err := GenerateMailLog(&campaign, &result, campaign.LaunchDate) ch.Assert(err, check.Equals, nil) m := MailLog{} diff --git a/models/models_test.go b/models/models_test.go index a4c1ed4c..8374911c 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -47,6 +47,8 @@ func (s *ModelsSuite) createCampaignDependencies(ch *check.C, optional ...string group.Targets = []Target{ Target{BaseRecipient: BaseRecipient{Email: "test1@example.com", FirstName: "First", LastName: "Example"}}, Target{BaseRecipient: BaseRecipient{Email: "test2@example.com", FirstName: "Second", LastName: "Example"}}, + Target{BaseRecipient: BaseRecipient{Email: "test3@example.com", FirstName: "Second", LastName: "Example"}}, + Target{BaseRecipient: BaseRecipient{Email: "test4@example.com", FirstName: "Second", LastName: "Example"}}, } group.UserId = 1 ch.Assert(PostGroup(&group), check.Equals, nil) diff --git a/models/result_test.go b/models/result_test.go index 5b81b9bf..54585d40 100644 --- a/models/result_test.go +++ b/models/result_test.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "net/mail" "regexp" "time" @@ -58,6 +59,25 @@ func (s *ModelsSuite) TestResultScheduledStatus(ch *check.C) { } } +func (s *ModelsSuite) TestResultVariableStatus(ch *check.C) { + c := s.createCampaignDependencies(ch) + c.LaunchDate = time.Now().UTC() + c.SendByDate = c.LaunchDate.Add(2 * time.Minute) + ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil) + + // The campaign has a window smaller than our group size, so we expect some + // emails to be sent immediately, while others will be scheduled + for _, r := range c.Results { + if r.SendDate.Before(c.CreatedDate) || r.SendDate.Equal(c.CreatedDate) { + fmt.Println("SENDING") + ch.Assert(r.Status, check.Equals, STATUS_SENDING) + } else { + fmt.Println("SCHEDULED") + ch.Assert(r.Status, check.Equals, STATUS_SCHEDULED) + } + } +} + func (s *ModelsSuite) TestDuplicateResults(ch *check.C) { group := Group{Name: "Test Group"} group.Targets = []Target{ diff --git a/static/js/dist/app/campaign_results.min.js b/static/js/dist/app/campaign_results.min.js index 265d9c70..c4767f80 100644 --- a/static/js/dist/app/campaign_results.min.js +++ b/static/js/dist/app/campaign_results.min.js @@ -1 +1 @@ -function dismiss(){$("#modal\\.flashes").empty(),$("#modal").modal("hide"),$("#resultsTable").dataTable().DataTable().clear().draw()}function deleteCampaign(){swal({title:"Are you sure?",text:"This will delete the campaign. This can't be undone!",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Delete Campaign",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,t){api.campaignId.delete(campaign.id).success(function(t){e()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Deleted!","This campaign has been deleted!","success"),$('button:contains("OK")').on("click",function(){location.href="/campaigns"})})}function completeCampaign(){swal({title:"Are you sure?",text:"Gophish will stop processing events for this campaign",type:"warning",animation:!1,showCancelButton:!0,confirmButtonText:"Complete Campaign",confirmButtonColor:"#428bca",reverseButtons:!0,allowOutsideClick:!1,showLoaderOnConfirm:!0,preConfirm:function(){return new Promise(function(e,t){api.campaignId.complete(campaign.id).success(function(t){e()}).error(function(e){t(e.responseJSON.message)})})}}).then(function(){swal("Campaign Completed!","This campaign has been completed!","success"),$("#complete_button")[0].disabled=!0,$("#complete_button").text("Completed!"),doPoll=!1})}function exportAsCSV(e){exportHTML=$("#exportButton").html();var t=null,a=campaign.name+" - "+capitalize(e)+".csv";switch(e){case"results":t=campaign.results;break;case"events":t=campaign.timeline}if(t){$("#exportButton").html('');var s=Papa.unparse(t,{}),i=new Blob([s],{type:"text/csv;charset=utf-8;"});if(navigator.msSaveBlob)navigator.msSaveBlob(i,a);else{var l=window.URL.createObjectURL(i),n=document.createElement("a");n.href=l,n.setAttribute("download",a),document.body.appendChild(n),n.click(),document.body.removeChild(n)}$("#exportButton").html(exportHTML)}}function replay(e){function t(){form.attr({action:url}),form.appendTo("body").submit().remove()}request=campaign.timeline[e],details=JSON.parse(request.details),url=null,form=$("