Skip to content

Commit d482d86

Browse files
authored
Merge pull request #8 from NxtTAB/patch-resend
feat: Add campaign and individual email resend functionality
2 parents 8e4241c + 260c973 commit d482d86

File tree

8 files changed

+290
-0
lines changed

8 files changed

+290
-0
lines changed

controllers/api/api_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/gophish/gophish/config"
1212
"github.com/gophish/gophish/models"
13+
"github.com/stretchr/testify/assert"
1314
)
1415

1516
type testContext struct {
@@ -112,3 +113,81 @@ func TestSiteImportBaseHref(t *testing.T) {
112113
t.Fatalf("unexpected response received. expected %s got %s", expected, cs.HTML)
113114
}
114115
}
116+
117+
func TestResendCampaign(t *testing.T) {
118+
ctx := setupTest(t)
119+
createTestData(t)
120+
121+
t.Run("Test ResendAll Success", func(t *testing.T) {
122+
req := httptest.NewRequest(http.MethodPost, "/api/campaigns/1/resendall", nil)
123+
req.Header.Set("Authorization", "Bearer "+ctx.apiKey)
124+
125+
rr := httptest.NewRecorder()
126+
ctx.apiServer.ServeHTTP(rr, req)
127+
128+
assert.Equal(t, http.StatusOK, rr.Code)
129+
count, _ := models.CountMailLogs(1)
130+
assert.Equal(t, int64(4), count, "Expected 4 total mail logs after resend")
131+
})
132+
133+
t.Run("Test ResendAll Authorization Failure", func(t *testing.T) {
134+
otherUser := models.User{Username: "other", Role: models.Role{Name: models.RoleUser}}
135+
models.PutUser(&otherUser)
136+
otherCampaign := models.Campaign{Name: "Other Campaign", UserId: otherUser.Id}
137+
models.PostCampaign(&otherCampaign, otherUser.Id)
138+
139+
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/campaigns/%d/resendall", otherCampaign.Id), nil)
140+
req.Header.Set("Authorization", "Bearer "+ctx.apiKey)
141+
142+
rr := httptest.NewRecorder()
143+
ctx.apiServer.ServeHTTP(rr, req)
144+
145+
assert.Equal(t, http.StatusNotFound, rr.Code)
146+
})
147+
}
148+
149+
func TestResendResult(t *testing.T) {
150+
ctx := setupTest(t)
151+
createTestData(t)
152+
153+
t.Run("Test Resend Single Result Success", func(t *testing.T) {
154+
// Get the first result from our test campaign to use its correct public RId
155+
result, err := models.GetFirstResultForCampaign(1)
156+
assert.NoError(t, err)
157+
158+
// Use the correct result.RId in the URL
159+
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/results/%s/resend", result.RId), nil)
160+
req.Header.Set("Authorization", "Bearer "+ctx.apiKey)
161+
162+
rr := httptest.NewRecorder()
163+
ctx.apiServer.ServeHTTP(rr, req)
164+
165+
assert.Equal(t, http.StatusOK, rr.Code, "Expected Status OK")
166+
167+
count, _ := models.CountMailLogs(1)
168+
assert.Equal(t, int64(3), count, "Expected 3 total mail logs after single resend")
169+
})
170+
171+
t.Run("Test Resend Single Result Authorization Failure", func(t *testing.T) {
172+
// Create a new, non-admin user
173+
regularUser := models.User{Username: "testuser", Role: models.Role{Name: models.RoleUser}}
174+
models.PutUser(&regularUser)
175+
176+
// FIX: We must reload the user from the database to get the generated API key.
177+
reloadedUser, err := models.GetUser(regularUser.Id)
178+
assert.NoError(t, err)
179+
180+
// The admin (ctx.admin) owns campaign 1, which was created by createTestData()
181+
resultToTest, _ := models.GetFirstResultForCampaign(1)
182+
183+
// Now, we make the API call AS the new regularUser by using their reloaded API key.
184+
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/results/%s/resend", resultToTest.RId), nil)
185+
req.Header.Set("Authorization", "Bearer "+reloadedUser.ApiKey)
186+
187+
rr := httptest.NewRecorder()
188+
ctx.apiServer.ServeHTTP(rr, req)
189+
190+
// The permission check should now fail correctly, giving us the 401 error we expect.
191+
assert.Equal(t, http.StatusUnauthorized, rr.Code)
192+
})
193+
}

controllers/api/campaign.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,51 @@ func (as *Server) FalsePositive(w http.ResponseWriter, r *http.Request) {
150150
JSONResponse(w, models.Response{Success: true, Message: "Event marked as false positive!"}, http.StatusOK)
151151
}
152152
}
153+
154+
// ResendAll resends all the emails in a campaign.
155+
func (as *Server) ResendAll(w http.ResponseWriter, r *http.Request) {
156+
switch r.Method {
157+
case http.MethodPost:
158+
vars := mux.Vars(r)
159+
user := ctx.Get(r, "user").(models.User)
160+
id, err := strconv.ParseInt(vars["id"], 10, 64)
161+
if err != nil {
162+
JSONResponse(w, models.Response{Success: false, Message: "Invalid campaign ID"}, http.StatusBadRequest)
163+
return
164+
}
165+
166+
_, err = models.GetCampaign(id, user.Id)
167+
if err != nil {
168+
JSONResponse(w, models.Response{Success: false, Message: "Campaign not found or access denied"}, http.StatusNotFound)
169+
return
170+
}
171+
172+
err = models.ResendAllResults(id)
173+
if err != nil {
174+
JSONResponse(w, models.Response{Success: false, Message: "Error queueing emails for resending"}, http.StatusInternalServerError)
175+
return
176+
}
177+
JSONResponse(w, models.Response{Success: true, Message: "Emails successfully queued for resending"}, http.StatusOK)
178+
default:
179+
JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusMethodNotAllowed)
180+
}
181+
}
182+
183+
// Resend resends a single email from a campaign.
184+
func (as *Server) Resend(w http.ResponseWriter, r *http.Request) {
185+
switch r.Method {
186+
case http.MethodPost:
187+
vars := mux.Vars(r)
188+
user := ctx.Get(r, "user").(models.User)
189+
rid := vars["rid"] // Get the string "rid" from the URL
190+
191+
err := models.ResendResultByRId(rid, user.Id)
192+
if err != nil {
193+
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
194+
return
195+
}
196+
JSONResponse(w, models.Response{Success: true, Message: "Email successfully queued for resending"}, http.StatusOK)
197+
default:
198+
JSONResponse(w, models.Response{Success: false, Message: "Method not allowed"}, http.StatusMethodNotAllowed)
199+
}
200+
}

controllers/api/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func (as *Server) registerRoutes() {
6767
router.HandleFunc("/campaigns/{id:[0-9]+}/results", as.CampaignResults)
6868
router.HandleFunc("/campaigns/{id:[0-9]+}/summary", as.CampaignSummary)
6969
router.HandleFunc("/campaigns/{id:[0-9]+}/complete", as.CampaignComplete)
70+
router.HandleFunc("/campaigns/{id:[0-9]+}/resendall", as.ResendAll)
71+
router.HandleFunc("/results/{rid}/resend", as.Resend)
7072
router.HandleFunc("/falsepositive/{id:[0-9]+}/rid/{rid:[a-zA-Z0-9]+}", as.FalsePositive)
7173
router.HandleFunc("/groups/", as.Groups)
7274
router.HandleFunc("/groups/summary", as.GroupsSummary)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/mattn/go-sqlite3 v2.0.3+incompatible
2626
github.com/oschwald/maxminddb-golang v1.6.0
2727
github.com/sirupsen/logrus v1.4.2
28+
github.com/stretchr/testify v1.4.0
2829
github.com/ziutek/mymysql v1.5.4 // indirect
2930
golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d
3031
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect

models/result.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package models
33
import (
44
"crypto/rand"
55
"encoding/json"
6+
"errors"
67
"math/big"
78
"net"
89
"time"
@@ -208,3 +209,64 @@ func GetResult(rid string) (Result, error) {
208209
err := db.Where("r_id=?", rid).First(&r).Error
209210
return r, err
210211
}
212+
213+
// ResendResultByRId finds a specific result by its public RId and requeues it for sending.
214+
func ResendResultByRId(rid string, user_id int64) error {
215+
r, err := GetResult(rid)
216+
if err != nil {
217+
return errors.New("Result not found")
218+
}
219+
220+
// Verify the user has access to this campaign
221+
_, err = GetCampaign(r.CampaignId, user_id)
222+
if err != nil {
223+
return errors.New("access denied")
224+
}
225+
226+
// Create a new MailLog entry to trigger the send operation by the mailer.
227+
m := &MailLog{
228+
CampaignId: r.CampaignId,
229+
UserId: r.UserId,
230+
SendDate: time.Now().UTC(),
231+
RId: r.RId,
232+
}
233+
return db.Create(m).Error
234+
}
235+
236+
// ResendAllResults finds all results for a given campaign and requeues them.
237+
func ResendAllResults(campaign_id int64) error {
238+
results := []Result{}
239+
err := db.Where("campaign_id = ?", campaign_id).Find(&results).Error
240+
if err != nil {
241+
return err
242+
}
243+
for _, r := range results {
244+
m := &MailLog{
245+
CampaignId: r.CampaignId,
246+
UserId: r.UserId,
247+
SendDate: time.Now().UTC(),
248+
RId: r.RId,
249+
}
250+
err = db.Create(m).Error
251+
if err != nil {
252+
return err
253+
}
254+
}
255+
return nil
256+
}
257+
258+
// CountMailLogs returns the number of MailLogs.
259+
// This is a helper function intended for use in tests.
260+
func CountMailLogs(cid int64) (int64, error) {
261+
var count int64
262+
err := db.Model(&MailLog{}).Where("campaign_id = ?", cid).Count(&count).Error
263+
return count, err
264+
}
265+
266+
// GetFirstResultForCampaign returns the first result for a given campaign.
267+
// This is a helper function intended for use in tests.
268+
func GetFirstResultForCampaign(cid int64) (Result, error) {
269+
r := Result{}
270+
err := db.Where("campaign_id = ?", cid).First(&r).Error
271+
return r, err
272+
}

static/js/src/app/campaign_results.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,17 @@ function load() {
852852
return reported
853853
},
854854
"targets": [7]
855+
},
856+
{
857+
orderable: false,
858+
"render": function(data, type, row) {
859+
// row[6] is Status, row[0] is RId, row[4] is email
860+
if (row[6] === "Email Sent") {
861+
return '<button class="btn btn-primary btn-xs" onclick="resendResult(\'' + row[0] + '\', \'' + row[4] + '\')">Resend</button>';
862+
}
863+
return '';
864+
},
865+
"targets": [9] // This targets our new, empty column
855866
}
856867
]
857868
});
@@ -1028,6 +1039,77 @@ function report_mail(rid, cid) {
10281039
})
10291040
}
10301041

1042+
// Function for the main "Resend All" button
1043+
function resendAll() {
1044+
var count = campaign.results ? campaign.results.length : 0;
1045+
var message = "This will resend emails to all " + count + " recipient(s) in this campaign.";
1046+
1047+
Swal.fire({
1048+
title: "Are you sure?",
1049+
text: message,
1050+
type: "warning",
1051+
animation: false,
1052+
showCancelButton: true,
1053+
confirmButtonText: "Yes, Resend All",
1054+
confirmButtonColor: "#428bca",
1055+
reverseButtons: true,
1056+
allowOutsideClick: false,
1057+
showLoaderOnConfirm: true,
1058+
preConfirm: function () {
1059+
return api.campaignId.resendAll(campaign.id);
1060+
}
1061+
}).then(function (result) {
1062+
if (result.value) {
1063+
Swal.fire(
1064+
'Emails Queued!',
1065+
'The emails have been queued for resending.',
1066+
'success'
1067+
);
1068+
}
1069+
}).catch(function(err) {
1070+
var message = "An error occurred";
1071+
if (err && err.responseJSON && err.responseJSON.message) {
1072+
message = err.responseJSON.message;
1073+
}
1074+
Swal.fire("Error", message, "error");
1075+
});
1076+
}
1077+
1078+
// Function for the individual "Resend" button
1079+
function resendResult(result_id, email) {
1080+
var message = "This will resend the email to " + escapeHtml(email) + ".";
1081+
1082+
Swal.fire({
1083+
title: "Are you sure?",
1084+
text: message,
1085+
type: "warning",
1086+
animation: false,
1087+
showCancelButton: true,
1088+
confirmButtonText: "Yes, Resend",
1089+
confirmButtonColor: "#428bca",
1090+
reverseButtons: true,
1091+
allowOutsideClick: false,
1092+
showLoaderOnConfirm: true,
1093+
preConfirm: function () {
1094+
return api.resultId.resend(result_id);
1095+
}
1096+
}).then(function (result) {
1097+
if (result.value) {
1098+
Swal.fire(
1099+
'Email Queued!',
1100+
'The email has been queued for resending.',
1101+
'success'
1102+
);
1103+
}
1104+
}).catch(function(err) {
1105+
var message = "An error occurred";
1106+
if (err && err.responseJSON && err.responseJSON.message) {
1107+
message = err.responseJSON.message;
1108+
}
1109+
Swal.fire("Error", message, "error");
1110+
});
1111+
}
1112+
10311113
$(document).ready(function () {
10321114
Highcharts.setOptions({
10331115
global: {

static/js/src/app/gophish.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,22 @@ var api = {
104104
complete: function (id) {
105105
return query("/campaigns/" + id + "/complete", "GET", {}, true)
106106
},
107+
// resendAll() - Resend all campaign emails at POST /campaigns/:id/resendall
108+
resendAll: function (id) {
109+
return query("/campaigns/" + id + "/resendall", "POST", {}, true)
110+
},
107111
// summary() - Queries the API for GET /campaigns/summary
108112
summary: function (id) {
109113
return query("/campaigns/" + id + "/summary", "GET", {}, true)
110114
}
111115
},
116+
// resultId contains the endpoint for /results/:id
117+
resultId: {
118+
// resend() - Resend a single email at POST /results/:id/resend
119+
resend: function(id) {
120+
return query("/results/" + id + "/resend", "POST", {}, true)
121+
}
122+
},
112123
// groups contains the endpoints for /groups
113124
groups: {
114125
// get() - Queries the API for GET /groups

templates/campaign_results.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ <h1 class="page-header" id="page-title">Results for campaign.name</h1>
3838
<span id="refresh_message">
3939
<i class="fa fa-spin fa-spinner"></i> Refreshing
4040
</span>
41+
<button type="button" class="btn btn-primary" onclick="resendAll()">
42+
<i class="fa fa-paper-plane"></i> Resend All
43+
</button>
4144
</div>
4245
<br />
4346
<div class="row">
@@ -77,6 +80,8 @@ <h2>Details</h2>
7780
<th>Position</th>
7881
<th>Status</th>
7982
<th class="text-center">Reported</th>
83+
<th></th>
84+
<th></th>
8085
</tr>
8186
</thead>
8287
<tbody>

0 commit comments

Comments
 (0)