Initial work on reported emails

pull/1894/head
Glenn Wilkinson 2020-07-09 09:19:31 +01:00
parent 3c74dd43e6
commit d046da81a5
11 changed files with 735 additions and 1 deletions

134
controllers/api/reported.go Normal file
View File

@ -0,0 +1,134 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"strings"
ctx "github.com/gophish/gophish/context"
"github.com/gophish/gophish/models"
"github.com/gorilla/mux"
)
/*
// ReportedEmailsSave handles requests for the /api/reportedemails/save endpoint
func (as *Server) ReportedEmailsSave(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
em := models.ReportedEmail{}
err := json.NewDecoder(r.Body).Decode(&em)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid email data."}, http.StatusBadRequest)
return
}
err = models.SaveReportedEmail(&em)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Successfully saved reported email."}, http.StatusCreated)
}
}*/
// ReportedEmailAttachment handles requests for the /api/reported/attachments endpoint
func (as *Server) ReportedEmailAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.ParseInt(vars["id"], 0, 64)
att, err := models.GetReportedEmailAttachment(ctx.Get(r, "user_id").(int64), id)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
//JSONResponse(w, ems, http.StatusOK)
data, err := base64.StdEncoding.DecodeString(att.Content)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", att.Header)
w.WriteHeader(http.StatusOK)
w.Write(data)
}
// ReportedEmails handles requests for the /api/reported endpoint
func (as *Server) ReportedEmails(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
emailid := int64(-1)
offset := int64(-1)
limit := int64(-1)
if _, ok := vars["id"]; ok {
emailid, _ = strconv.ParseInt(vars["id"], 0, 64)
}
if _, ok := vars["range"]; ok {
r := strings.Split(vars["range"], ",")
offset, _ = strconv.ParseInt(r[0], 0, 64)
limit, _ = strconv.ParseInt(r[1], 0, 64)
}
switch {
// GET: Return all emails
case r.Method == "GET":
ems, err := models.GetReportedEmails(ctx.Get(r, "user_id").(int64), emailid, limit, offset)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
JSONResponse(w, ems, http.StatusOK)
// PUT: Update an email
case r.Method == "PUT":
// Get existing email by id
ems, err := models.GetReportedEmail(ctx.Get(r, "user_id").(int64), emailid)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
if len(ems) > 0 {
em := ems[0]
err := json.NewDecoder(r.Body).Decode(&em)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Invalid data"}, http.StatusBadRequest)
return
}
err = models.SaveReportedEmail(em)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Failed to update email"}, http.StatusBadRequest)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Email record udpated"}, http.StatusCreated)
} else {
JSONResponse(w, models.Response{Success: false, Message: "Unable to locate email"}, http.StatusCreated)
}
case r.Method == "DELETE":
ems, err := models.GetReportedEmail(ctx.Get(r, "user_id").(int64), emailid)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: err.Error()}, http.StatusInternalServerError)
return
}
if len(ems) > 0 {
err := models.DeleteReportedEmail(emailid)
if err != nil {
JSONResponse(w, models.Response{Success: false, Message: "Failed to delete email"}, http.StatusBadRequest)
return
}
JSONResponse(w, models.Response{Success: true, Message: "Email deleted"}, http.StatusCreated)
} else {
JSONResponse(w, models.Response{Success: false, Message: "Unable to locate email"}, http.StatusCreated)
}
}
}

View File

@ -76,6 +76,10 @@ func (as *Server) registerRoutes() {
router.HandleFunc("/webhooks/", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/webhooks/{id:[0-9]+}/validate", mid.Use(as.ValidateWebhook, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/webhooks/{id:[0-9]+}", mid.Use(as.Webhook, mid.RequirePermission(models.PermissionModifySystem)))
router.HandleFunc("/reported/", as.ReportedEmails) // Return all reported emails
router.HandleFunc("/reported/{id:[0-9]+}", as.ReportedEmails) // Fetch an individual email e.g /reported/3
router.HandleFunc("/reported/{range:[0-9]+,[0-9]+}", as.ReportedEmails) // Fetch a range of emails e.g. /reported/0,10
router.HandleFunc("/reported/attachment/{id:[0-9]+}", as.ReportedEmailAttachment) // Download attachment
as.handler = router
}

View File

@ -4,9 +4,11 @@ import (
"compress/gzip"
"context"
"crypto/tls"
"encoding/base64"
"html/template"
"net/http"
"net/url"
"strconv"
"time"
"github.com/NYTimes/gziphandler"
@ -111,6 +113,9 @@ func (as *AdminServer) registerRoutes() {
router.HandleFunc("/settings", mid.Use(as.Settings, mid.RequireLogin))
router.HandleFunc("/users", mid.Use(as.UserManagement, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/webhooks", mid.Use(as.Webhooks, mid.RequirePermission(models.PermissionModifySystem), mid.RequireLogin))
router.HandleFunc("/reported", mid.Use(as.Reported, mid.RequireLogin))
router.HandleFunc("/reported/attachment/{id:[0-9]+}", mid.Use(as.ReportedEmailAttachment, mid.RequireLogin))
// Create the API routes
api := api.NewServer(api.WithWorker(as.worker))
router.PathPrefix("/api/").Handler(api)
@ -246,6 +251,36 @@ func (as *AdminServer) Webhooks(w http.ResponseWriter, r *http.Request) {
getTemplate(w, "webhooks").ExecuteTemplate(w, "base", params)
}
// Reported handles the display of user reported emails that aren't Gophish campaigns
func (as *AdminServer) Reported(w http.ResponseWriter, r *http.Request) {
params := newTemplateParams(r)
params.Title = "Reported Emails"
getTemplate(w, "reported").ExecuteTemplate(w, "base", params)
}
// ReportedEmailAttachment retrieves an attachment by id
func (as *AdminServer) ReportedEmailAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
user := ctx.Get(r, "user").(models.User)
attID, _ := strconv.ParseInt(vars["id"], 0, 64)
att, err := models.GetReportedEmailAttachment(user.Id, attID)
if err != nil {
log.Error(err)
w.Write([]byte("Unable to query attachment"))
} else {
data, err := base64.StdEncoding.DecodeString(att.Content)
if err != nil {
w.Write([]byte("Unable to load attachment"))
} else {
w.Header().Set("Content-Type", att.Header)
w.WriteHeader(http.StatusOK)
w.Write(data)
}
}
}
// Login handles the authentication flow for a user. If credentials are valid,
// a session is created
func (as *AdminServer) Login(w http.ResponseWriter, r *http.Request) {

View File

@ -0,0 +1,10 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS `reported` (id bigint primary key autoincrement, user_id integer ,reported_by_name varchar(255), reported_by_email varchar(255), reported_time datetime, reported_html varchar(255), reported_text varchar(255), reported_subject varchar(255),imap_uid varchar(255), status varchar(255), notes varchar(255));
CREATE TABLE IF NOT EXISTS `reported_attachments` (id bigint primary key autoincrement, rid bigint, filename varchar(255), header varchar(255), size bigint, content varchar(255));
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE `reported`;
DROP TABLE `reported_attachments`;

View File

@ -0,0 +1,10 @@
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS "reported" ("id" integer primary key autoincrement, "user_id" integer ,"reported_by_name" varchar(255), "reported_by_enaio" varchar(255) "reported_time" datetime, "reported_html" varchar(255), "reported_text" varchar(255), "reported_subject" varchar(255),"imap_uid" varchar(255), "status" varchar(255), "notes" varchar(255));
CREATE TABLE IF NOT EXISTS "reported_attachments" ("id" integer primary key autoincrement, "rid" integer, "filename" varchar(255), "header" varchar(255), "size" integer, "content" varchar(255));
-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE "reported";
DROP TABLE "reported_attachments";

View File

@ -9,6 +9,8 @@ package imap
import (
"bytes"
"context"
"encoding/base64"
"net/mail"
"regexp"
"strconv"
"strings"
@ -156,8 +158,33 @@ func checkForNewEmails(im models.IMAP) {
log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error())
} else {
if len(rids) < 1 {
// In the future this should be an alert in Gophish
log.Infof("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.", m.Email.From, m.Email.Subject)
// Save reported email to the database
atts := []*models.ReportedAttachment{}
for _, a := range m.Attachments {
na := &models.ReportedAttachment{Filename: a.Filename, Header: a.Header.Get("Content-Type"), Size: len(a.Content), Content: base64.StdEncoding.EncodeToString(a.Content)}
atts = append(atts, na)
}
e, err := mail.ParseAddress(m.Email.From)
if err != nil {
log.Error(err)
}
em := &models.ReportedEmail{
UserId: im.UserId,
ReportedByName: e.Name,
ReportedByEmail: e.Address,
ReportedHTML: string(m.HTML),
ReportedText: string(m.Text),
ReportedSubject: string(m.Subject),
IMAPUID: -1, // https://github.com/emersion/go-imap/issues/353
ReportedTime: time.Now().UTC(),
Attachments: atts,
Status: "Unknown"}
models.SaveReportedEmail(em)
}
for rid := range rids {
log.Infof("User '%s' reported email with rid %s", m.Email.From, rid)

136
models/reported.go Normal file
View File

@ -0,0 +1,136 @@
package models
import (
"time"
log "github.com/gophish/gophish/logger"
)
// ReportedEmail contains the attributes for non-campaign emails reported by users
type ReportedEmail struct {
//Id int64 `json:"id" gorm:"column:id; primary_key:yes;AUTO_INCREMENT"`
Id int64 `json:"id"`
UserId int64 `json:"user_id"` // ID of the user account
ReportedByName string `json:"reported_by_name"` // Email of the user reporting the email
ReportedByEmail string `json:"reported_by_email"` // Email of the user reporting the email
ReportedTime time.Time `json:"reported_time"` // When the email was reported
ReportedHTML string `json:"reported_html"`
ReportedText string `json:"reported_text"`
ReportedSubject string `json:"reported_subject"`
/*EmailFrom string `json:"email_from"`
EmailTo string `json:"email_to"`
EmailCC string `json:"email_cc"`
EmailSubject string `json:"email_subject"`
EmailTime string `json:"email_time"`
EmailText string `json:"email_text"`
EmailHTML string `json:"email_html"`
EmailHeaders string `json:"email_headers"`
EmailBlob string `json:"email_blob"`*/
IMAPUID int64 `json:"imap_uid"`
Status string `json:"status"`
Notes string `json:"notes"` // Free form notes for operator to give additional info
Attachments []*ReportedAttachment `json:"attachments" gorm:"foreignkey:Rid"`
}
//Todo: Add Enabled boolean, and attachments option
// ReportedEmailAttachment contains email attachments
type ReportedAttachment struct {
Rid int64 `json:"-"` // Foreign key
Id int64 `json:"id"`
Filename string `json:"filename"`
Header string `json:"header"`
Size int `json:"size"` // File size in bytes
Content string `json:"content,omitempty"`
}
// TableName specifies the database tablename for Gorm to use
func (em ReportedEmail) TableName() string {
return "reported"
}
// GetReportedEmailAttachment gets an attachment
func GetReportedEmailAttachment(uid, id int64) (ReportedAttachment, error) {
att := ReportedAttachment{}
err := db.Debug().Table("reported_attachments").Select("reported_attachments.filename, reported_attachments.header, reported_attachments.content").Joins("left join reported on reported.id = reported_attachments.rid").Where("reported.user_id=? AND reported_attachments.id=?", uid, id).Take(&att).Error
return att, err
}
// GetReportedEmails gets reported emails
func GetReportedEmails(uid, emailid, limit, offset int64) ([]*ReportedEmail, error) {
ems := []*ReportedEmail{}
var err error
// We have three conditions; fetch all email, fetch one email by id, or fetch a subset of emails by limit and offset
if emailid == -1 {
if offset == -1 {
err = db.Debug().Preload("Attachments").Where("user_id=?", uid).Find(&ems).Error
} else {
err = db.Preload("Attachments").Where("user_id=?", uid).Order("ReportedTime", true).Offset(offset).Limit(limit).Find(&ems).Error
}
} else {
err = db.Preload("Attachments").Where("user_id=? AND id=?", uid, emailid).Find(&ems).Error
}
if err != nil {
log.Error(err)
}
// Remove attachmetns and HTML/plaintext content for bulk requests. TODO: Don't retrieve these in the first place. Maybe with joins.
if emailid == -1 {
for _, e := range ems {
e.ReportedHTML = ""
e.ReportedText = ""
if len(e.Attachments) > 0 { // Remove attachment content, but leave other details (filename, size, header)
for _, a := range e.Attachments {
a.Content = ""
}
}
}
}
// Reverse order so newest emails are first. Could not figure out how to add ORDER to the Preload() in the queries
for i, j := 0, len(ems)-1; i < j; i, j = i+1, j-1 {
ems[i], ems[j] = ems[j], ems[i]
}
return ems, err
}
// GetReportedEmail gets a single reported emails
func GetReportedEmail(uid, emailid int64) ([]*ReportedEmail, error) {
ems := []*ReportedEmail{}
err := db.Preload("Attachments").Where("user_id=? AND id=?", uid, emailid).Find(&ems).Error
if err != nil {
log.Error(err)
}
return ems, err
}
// SaveReportedEmail updates IMAP settings for a user in the database.
func SaveReportedEmail(em *ReportedEmail) error {
// Insert into the DB
err := db.Save(em).Error
if err != nil {
log.Error("Unable to save to database: ", err.Error())
}
return err
}
// DeleteReportedEmail deletes
func DeleteReportedEmail(id int64) error {
err := db.Where("id=?", id).Delete(&ReportedEmail{}).Error
if err != nil {
log.Error(err)
}
return err
}

View File

@ -277,6 +277,27 @@ var api = {
return query("/webhooks/" + id + "/validate", "POST", {}, true)
},
},
// report handles (non-campaign) reported emails
reported: {
get: function() {
return query("/reported/", "GET", {}, !1)
},
getone: function(id) {
return query("/reported/" + id, "GET", {}, true)
},
//post: function(email) {
// return query("/reported/" + email.id, "POST", email, true)
//},
put: function (email) {
return query("/reported/" + email.id, "PUT", email, true)
},
delete: function(id) {
return query("/reported/" + id, "DELETE", {}, false)
},
//update: function(e) {
// return query("/reported/update", "POST", e, true)
//}
},
// import handles all of the "import" functions in the api
import_email: function (req) {
return query("/import/email", "POST", req, false)

View File

@ -0,0 +1,243 @@
let emails = []
let notes = {}
let statusBtnClass = {
"Unknown" : "btn btn-warning dropdown-toggle statusbtn",
"Safe" : "btn btn-success dropdown-toggle statusbtn",
"Harmful" : "btn btn-danger dropdown-toggle statusbtn"
}
const load = () => {
$("#reportedTable").hide()
$("#loading").show()
api.reported.get()
.success((em) => {
emails = em
$("#loading").hide()
$("#reportedTable").show()
let reportedTable = $("#reportedTable").DataTable({
destroy: true,
"aaSorting": [], // Disable default sort
columnDefs: [{
orderable: false,
targets: "no-sort"
}]
});
reportedTable.clear();
$.each(emails, (i, email) => {
statusBtn = '<div class="dropdown">\
<button id="btnstatus-'+email.id+'" class="' + statusBtnClass[email.status] + '" data-toggle="dropdown">' + email.status + '</button>\
<ul class="dropdown-menu" id="' + email.id + '">\
<li><a onclick="updateStatus(' + email.id + ', \'Safe\')" href="#">Safe</a></li>\
<li><a onclick="updateStatus(' + email.id + ', \'Harmful\')" href="#">Harmful</a></li>\
<li><a onclick="updateStatus(' + email.id + ', \'Unknown\')" href="#">Unknown</a></li>\
<li class="divider"></li>\
<li><a onclick="loadNotes(' + email.id + ')" class="notes" href="#">Notes</a></li>\
</ul>\
</div>'
notes[email.id] = email.notes
attHTML =
"<div class='dropdown'>\
<button class='btn btn-primary dropdown-toggle' type='button' data-toggle='dropdown'>" + email.attachments.length + " files\
<span class='caret'></span></button>"
if (email.attachments.length > 0 ){
attHTML += "<ul class='dropdown-menu'>"
$.each(email.attachments, function(i, a){
attHTML += "<li id="+ a.id +"><a href=\"/reported/attachment/\" onclick=\"window.open('/reported/attachment/" + a.id + "', 'newwindow', 'width=640, height=480'); return false;\">" + a.filename + " ("+ humanFileSize(a.size) +")</a></li>"
});
attHTML += "</ul>"
}
attHTML += "</div>"
subj = escapeHtml(email.reported_subject)
if (subj.length > 24) {
subj = subj.substring(0, 24) + "..."
}
reportedTable.row.add([
"<span data-toggle='tooltip' title='" + escapeHtml(email.reported_by_name) + "'> " + escapeHtml(email.reported_by_email) + "</span>",
//escapeHtml(email.reported_subject.substring(0, 24) + "..."),
subj,
moment.utc(email.reported_time).fromNow(),
attHTML,
statusBtn,
"<div class=''><button class='btn btn-primary edit_button' onclick='viewEmail(" + email.id + ")' data-backdrop='static' data-user-id='" + email.id + "'>\
<i class='fa fa-eye'></i>\
</button>\
<button class='btn btn-danger delete_button' onclick='deleteEmail(" + email.id + ")' data-user-id='" + email.id + "'>\
<i class='fa fa-trash-o'></i>\
</button></div>"
]).draw()
})
})
.error(() => {
errorFlash("Error fetching reported emails")
})
}
$(document).ready(function () {
load()
window.setInterval(function(){ //Refresh every 10 seconds
load()
}, 10000);
});
function updateStatus(emailID, newstatus){
// Update button
btn = $("#btnstatus-" + emailID)
btn.attr('class', statusBtnClass[newstatus])
btn.text(newstatus)
btn.val(newstatus)
//Update server side value
email = {
id: parseInt(emailID),
status: newstatus
}
api.reported.put(email)
.error(function (data) {
Swal.fire({
type: 'error',
title: data.responseJSON.message
})
})
}
function loadNotes(emailID){
email = {
id: parseInt(emailID),
}
$("#notes").val(notes[emailID]);
$("#notes-emailid").val(emailID);
$("#modal\\.flashes").empty()
$('#modal').modal('show');
}
function deleteEmail(id) {
Swal.fire({
title: "Are you sure?",
text: "This will delete the email from here, but not from your mail server.",
type: "warning",
animation: false,
showCancelButton: true,
confirmButtonText: "Delete",
confirmButtonColor: "#428bca",
reverseButtons: true,
allowOutsideClick: false,
preConfirm: function () {
return new Promise((resolve, reject) => {
api.reported.delete(id)
.success((msg) => {
resolve()
})
.error((data) => {
reject(data.responseJSON.message)
})
})
.catch(error => {
Swal.showValidationMessage(error)
})
}
}).then(function (result) {
load()
})
}
function viewEmail(id){
$("#modal-email\\.flashes").empty()
$('#modal-email').modal('show');
api.reported.getone(id)
.success((em) => {
if (em.length > 0 ) { // Should always be one, but safe to check.
rtext = em[0].reported_text.replace(/(?:\r\n|\r|\n)/g, '<br>');
rhtml = em[0].reported_html
//$("#email-plaintext").attr('value', btoa(rtext))
//$("#email-html").attr('value', btoa(rhtml))
$("#email-plaintext").data("value", rtext)
$("#email-html").data("value", rhtml)
$("#email-body").attr("srcdoc", rtext); // Load plaintext by default
} else {
modalError("Error loading email")
}
})
.error(function (data) {
modalError("Error loading email: " + data.responseJSON.message)
})
};
function viewplaintext() {
rtext = $("#email-plaintext").data("value")
$("#email-body").attr("srcdoc", rtext);
}
function viewhtml() {
rhtml = $("#email-html").data("value")
$("#email-body").attr("srcdoc", rhtml);
}
$("#modalSubmit").unbind('click').click(() => {
emailID = $("#notes-emailid").attr('value')
newnotes = $("#notes").val()
notes[emailID] = newnotes
email = {
"id": parseInt(emailID),
"notes": notes[emailID]
}
api.reported.put(email)
.success(function (data) {
$("#modal").modal('hide')
})
.error(function (data) {
modalError("Error saving notes: " + data.responseJSON.message)
})
})
// Convert attachment byte file size to human readable format
function humanFileSize(bytes, si=true, dp=0) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kb', 'mb', 'gb', 'tb', 'pb', 'eb', 'zb', 'yb']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10**dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}

View File

@ -20,6 +20,9 @@
<li>
<a href="/sending_profiles">Sending Profiles</a>
</li>
<li>
<a href="/reported">Reported Emails</a>
</li>
<li>
<a href="/settings">Account Settings</span></a>
</li>

111
templates/reported.html Normal file
View File

@ -0,0 +1,111 @@
{{define "body"}}
<style>
.statusbtn {
width: 90px !important;
}
</style>
<div class="col-sm-9 col-sm-offset-3 col-md-10 col-md-offset-2 main">
<div class="row">
<h1 class="page-header">
{{.Title}}
</h1>
</div>
<div id="flashes" class="row"></div>
<div id="loading">
<i class="fa fa-spinner fa-spin fa-4x"></i>
</div>
<div id="emptyMessage" class="row" style="display:none;">
<div class="alert alert-info">
<p>Suspicious emails that your users have reported will be available here.</p>
</div>
</div>
<div class="row">
<table id="reportedTable" class="table" style="display:none;">
<thead>
<tr>
<th>Reported by</th>
<th>Subject</th>
<th>When reported</th>
<th>Attachments</th>
<th>Status</th>
<th class="col-md-2 no-sort">Actions</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<!-- Notes Modal -->
<div class="modal fade" id="modal" tabindex="-1" role="dialog" aria-labelledby="modalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="groupModalLabel">Notes</h4>
</div>
<div class="modal-body" id="modal_body">
<div class="row" id="modal.flashes"></div>
<div class="form-group">
<textarea rows="10" id="notes" class="gophish-editor form-control" placeholder="Enter notes here"></textarea>
</div>
<input type="hidden" id="notes-emailid" value="">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="modalSubmit">Save changes</button>
</div>
</div>
</div>
</div>
<!-- / Notes Modal -->
<!-- Email viewer Modal -->
<div class="modal fade" id="modal-email" tabindex="-1" role="dialog" aria-labelledby="modalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="groupModalLabel">Reported email:</h4>
</div>
<div class="modal-body" id="modal_body">
<div class="row" id="modal.flashes"></div>
<!--<div id="email-body"></div>-->
<!--<iframe id="email-body" sandbox></iframe>-->
<div class="embed-responsive embed-responsive-16by9">
<iframe id="email-body" class="embed-responsive-item" sandbox></iframe>
</div>
<input type="hidden" id="email-html" value="">
<input type="hidden" id="email-plaintext" value="">
</div>
<div class="modal-footer">
<button type="button" onclick="viewplaintext()" class="btn btn-primary">View plaintext</button>
<button type="button" onclick="viewhtml()" class="btn btn-primary">View HTML</button>
</div>
</div>
</div>
</div>
<!-- / Email viewer Modal -->
{{end}} {{define "scripts"}}
<!-- <script src="/js/dist/app/reported.min.js"></script> -->
<!-- <script src="https://raw.githubusercontent.com/HubSpot/humanize/master/dist/humanize.min.js"></script> -->
<script src="/js/src/app/reported.js"></script>
{{end}}