added anonymous campaigns and position/department stats across campaigns and reports.

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-09-05 10:02:07 +02:00
parent 5824e52aee
commit 0a08a245fa
23 changed files with 2095 additions and 298 deletions
+4
View File
@@ -170,6 +170,8 @@ const (
ROUTE_V1_CAMPAIGN_NAME = "/api/v1/campaign/name/:name"
ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients"
ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics"
ROUTE_V1_CAMPAIGN_GROUPED_STATS = "/api/v1/campaign/:id/grouped-statistics"
ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA = "/api/v1/campaign/:id/has-group-data"
ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events"
ROUTE_V1_CAMPAIGN_ALL_EVENTS = "/api/v1/campaign/events"
ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id"
@@ -501,6 +503,8 @@ func setupRoutes(
DELETE(ROUTE_V1_CAMPAIGN_EVENT_ID, middleware.SessionHandler, controllers.Campaign.DeleteEventByID).
GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats).
GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats).
GET(ROUTE_V1_CAMPAIGN_GROUPED_STATS, middleware.SessionHandler, controllers.Campaign.GetGroupedResultStats).
GET(ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA, middleware.SessionHandler, controllers.Campaign.GetHasGroupData).
GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats).
GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats).
POST(ROUTE_V1_CAMPAIGN_STATS_CREATE, middleware.SessionHandler, controllers.Campaign.CreateCampaignStats).
+69 -2
View File
@@ -692,6 +692,54 @@ func trainingMilestoneEventName(pageType string) string {
}
}
// applyEventAnonymization strips identity and stamps the recipient's pseudonym on an
// event for an anonymous campaign, so it stays countable but carries no identity. It
// is a no-op for a normal campaign. Call before persisting an event.
func (s *Server) applyEventAnonymization(
ctx context.Context,
campaign *model.Campaign,
campaignID uuid.UUID,
recipientID uuid.UUID,
event *model.CampaignEvent,
) {
if campaign == nil {
return
}
// resolve anonymity; if the flag is unset fall back to the db, and strip identity
// if it still cannot be determined (fail closed)
isAnon, err := campaign.IsAnonymous.Get()
if err != nil {
isAnon, err = s.repositories.Campaign.IsAnonymousByID(ctx, &campaignID)
if err != nil {
s.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err)
event.Anonymize(nil)
return
}
}
if !isAnon {
return
}
// strip identity and attach the pseudonym if it loads; on failure the event is
// stored uncounted rather than with identity (fail closed)
cr, err := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID(
ctx,
&campaignID,
&recipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
s.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err)
event.Anonymize(nil)
return
}
aid, err := cr.AnonymizedID.Get()
if err != nil {
event.Anonymize(nil)
return
}
event.Anonymize(&aid)
}
// emitTrainingMilestoneIfNeeded records a training_started or training_completed
// event for a training campaign, at most once per recipient. The raw page visit
// event is still recorded separately, so this adds a deduplicated milestone on
@@ -720,8 +768,20 @@ func (s *Server) emitTrainingMilestoneIfNeeded(
if milestoneEventID == nil {
return nil
}
// a milestone is recorded once per recipient per campaign
has, err := s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID)
// a milestone is recorded once per recipient per campaign. an anonymous
// campaign's events carry the pseudonym rather than a recipient id, so dedup on
// that; otherwise a milestone would be recorded on every page visit.
var has bool
var err error
if isAnon, _ := campaign.IsAnonymous.Get(); isAnon {
if cr, crErr := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID(c, &campaignID, &recipientID, &repository.CampaignRecipientOption{}); crErr == nil {
if aid, aerr := cr.AnonymizedID.Get(); aerr == nil {
has, err = s.repositories.Campaign.HasEventByAnonymizedID(c, &campaignID, &aid, milestoneEventID)
}
}
} else {
has, err = s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID)
}
if err != nil {
s.logger.Errorw("failed to check for existing training milestone event", "error", err)
// continue and attempt to create it
@@ -742,6 +802,7 @@ func (s *Server) emitTrainingMilestoneIfNeeded(
Data: vo.NewEmptyOptionalString1MB(),
Metadata: model.ExtractCampaignEventMetadata(c, campaign),
}
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
if err := s.repositories.Campaign.SaveEvent(c, event); err != nil {
return fmt.Errorf("failed to save training milestone event: %s", err)
}
@@ -1308,6 +1369,7 @@ func (s *Server) checkAndServePhishingPage(
Data: submittedData,
Metadata: metadata,
}
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
err = s.repositories.Campaign.SaveEvent(c, event)
if err != nil {
return true, fmt.Errorf("failed to save campaign event: %s", err)
@@ -1456,6 +1518,7 @@ func (s *Server) checkAndServePhishingPage(
}
// save the synthetic message read event
s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent)
err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent)
if err != nil {
s.logger.Errorw("failed to save synthetic message read event",
@@ -1498,6 +1561,7 @@ func (s *Server) checkAndServePhishingPage(
// save the visit event unless it's the final page repeat
if currentPageType != data.PAGE_TYPE_DONE {
s.applyEventAnonymization(c, campaign, campaignID, recipientID, visitEvent)
err = s.repositories.Campaign.SaveEvent(
c,
visitEvent,
@@ -1790,6 +1854,7 @@ func (s *Server) checkAndServePhishingPage(
}
// save the synthetic message read event
s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent)
err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent)
if err != nil {
s.logger.Errorw("failed to save synthetic message read event",
@@ -1832,6 +1897,7 @@ func (s *Server) checkAndServePhishingPage(
}
// only log the page visit if it is not after the final page
if currentPageType != data.PAGE_TYPE_DONE {
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
err = s.repositories.Campaign.SaveEvent(
c,
event,
@@ -2034,6 +2100,7 @@ func (s *Server) renderDenyPage(
Metadata: metadata,
}
s.applyEventAnonymization(c, campaign, campaignID, recipientID, event)
err = s.repositories.Campaign.SaveEvent(c, event)
if err != nil {
s.logger.Errorw("failed to save deny page visit event",
+48
View File
@@ -265,6 +265,54 @@ func (c *Campaign) GetResultStats(g *gin.Context) {
c.Response.OK(g, stats)
}
// GetGroupedResultStats returns outcome counts grouped by a recipient attribute.
// The group is chosen with the `by` query parameter and is restricted to the
// snapshot columns position and department; small groups are suppressed by the
// service so an individual cannot be re-identified.
func (c *Campaign) GetGroupedResultStats(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
groupBy := g.DefaultQuery("by", "position")
if groupBy != "position" && groupBy != "department" {
c.Response.BadRequest(g)
return
}
stats, err := c.CampaignService.GetGroupedResultStats(
g.Request.Context(),
session,
id,
groupBy,
)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, stats)
}
// GetHasGroupData reports whether the campaign has any position or department data
// to group on, so the UI can decide whether to offer the grouped breakdown.
func (c *Campaign) GetHasGroupData(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
id, ok := c.handleParseIDParam(g)
if !ok {
return
}
has, err := c.CampaignService.HasGroupData(g.Request.Context(), session, id)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{"hasGroupData": has})
}
// GetCampaignStats get campaign stats
// if no company id is provided it gets the global stats including all companies
func (c *Campaign) GetStats(g *gin.Context) {
+139 -26
View File
@@ -172,7 +172,7 @@ func (a *activeSession) runScreencast(ctx context.Context, page *rod.Page, t *sc
proto.RuntimeEvaluate{Expression: "window.requestAnimationFrame(function(){void 0})"}.Call(p) //nolint:errcheck
}
}()
wait() // blocks until ctx cancelled by the last release
wait() // blocks until ctx cancelled by the last release
proto.PageStopScreencast{}.Call(page) //nolint:errcheck
}
@@ -728,27 +728,27 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
return
}
var cmd struct {
Type string `json:"type"`
Event string `json:"event"`
Data json.RawMessage `json:"data"`
Name string `json:"name"`
Action string `json:"action"`
X float64 `json:"x"`
Y float64 `json:"y"`
Button string `json:"button"`
DeltaX float64 `json:"deltaX"`
DeltaY float64 `json:"deltaY"`
Key string `json:"key"`
Code string `json:"code"`
KeyCode int64 `json:"keyCode"`
Modifiers int64 `json:"modifiers"`
CharText string `json:"charText"`
Text string `json:"text"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Dpr float64 `json:"dpr"`
ScreenWidth float64 `json:"screenWidth"`
ScreenHeight float64 `json:"screenHeight"`
Type string `json:"type"`
Event string `json:"event"`
Data json.RawMessage `json:"data"`
Name string `json:"name"`
Action string `json:"action"`
X float64 `json:"x"`
Y float64 `json:"y"`
Button string `json:"button"`
DeltaX float64 `json:"deltaX"`
DeltaY float64 `json:"deltaY"`
Key string `json:"key"`
Code string `json:"code"`
KeyCode int64 `json:"keyCode"`
Modifiers int64 `json:"modifiers"`
CharText string `json:"charText"`
Text string `json:"text"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Dpr float64 `json:"dpr"`
ScreenWidth float64 `json:"screenWidth"`
ScreenHeight float64 `json:"screenHeight"`
}
if json.Unmarshal(msg, &cmd) != nil {
continue
@@ -1016,15 +1016,40 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) {
return
}
campaignFilter := g.Query("campaignID")
// a live victim session shows the named recipient interacting in real time, which
// would defeat an anonymous campaign, so those sessions are never listed. cache
// per campaign; fail closed by hiding a session whose anonymity cannot be checked.
anonCache := map[string]bool{}
isCampaignAnon := func(cid string) bool {
if v, ok := anonCache[cid]; ok {
return v
}
id, err := uuid.Parse(cid)
if err != nil {
anonCache[cid] = true
return true
}
anon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &id)
if err != nil {
m.Logger.Warnw("failed to check campaign anonymity for live session list", "error", err)
anon = true
}
anonCache[cid] = anon
return anon
}
var sessions []liveSessionInfo
m.RemoteBrowserService.RangeSessions(func(_ string, val service.LiveSession) bool {
sess := val.(*activeSession)
if sess.isTest {
return true
}
if campaignFilter == "" || sess.CampaignID.String() == campaignFilter {
sessions = append(sessions, m.sessionToInfo(sess))
if campaignFilter != "" && sess.CampaignID.String() != campaignFilter {
return true
}
if isCampaignAnon(sess.CampaignID.String()) {
return true
}
sessions = append(sessions, m.sessionToInfo(sess))
return true
})
if sessions == nil {
@@ -1033,6 +1058,29 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) {
m.Response.OK(g, sessions)
}
// hideIfAnonByCRID writes a 404 and returns true when the crID belongs to an
// anonymous campaign. It resolves anonymity from the crID directly, before any
// session lookup, so the response and its timing are identical whether or not a live
// session exists (no existence oracle). A crID matching no recipient (test runs)
// returns false. Fails closed: on a lookup error it hides with 404.
func (m *RemoteBrowserController) hideIfAnonByCRID(g *gin.Context, crID string) bool {
id, err := uuid.Parse(crID)
if err != nil {
return false
}
isAnon, found, err := m.CampaignRepository.IsAnonymousByCampaignRecipientID(g.Request.Context(), &id)
if err != nil {
m.Logger.Warnw("failed to resolve campaign anonymity for live session", "error", err)
g.AbortWithStatus(http.StatusNotFound)
return true
}
if found && isAnon {
g.AbortWithStatus(http.StatusNotFound)
return true
}
return false
}
// CloseLiveSession terminates an active victim session by cancelling its context.
func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) {
if !m.isEnabled(g) {
@@ -1050,6 +1098,13 @@ func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) {
return
}
crID := g.Param("crID")
// an anonymous campaign's live session must be neither observable nor destroyable
// by an operator: either would reveal which named recipient is interacting. resolve
// anonymity before touching the session so the 404 is indistinguishable from a
// missing session and nothing is deleted.
if m.hideIfAnonByCRID(g, crID) {
return
}
val, loaded := m.RemoteBrowserService.LoadAndDeleteSession(crID)
if !loaded {
g.AbortWithStatus(http.StatusNotFound)
@@ -1080,12 +1135,32 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
return
}
crIDStr := g.Param("crID")
// resolve anonymity before the session lookup so an anonymous campaign returns the
// same 404 with the same timing whether or not a session exists (no existence
// oracle, including via the extra query a post-lookup check would add).
if m.hideIfAnonByCRID(g, crIDStr) {
return
}
val, exists := m.RemoteBrowserService.LoadSession(crIDStr)
if !exists {
g.AbortWithStatus(http.StatusNotFound)
return
}
sess := val.(*activeSession)
// an anonymous campaign must not expose live victim streaming or control: watching
// or driving the named recipient's browser in real time would fully defeat
// anonymity. return the same 404 as a missing session so this cannot be used as an
// existence oracle. fail closed if anonymity cannot be confirmed. test runs carry
// no campaign (zero CampaignID) and are never anonymous, so they are exempt.
if !sess.isTest {
if isAnon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &sess.CampaignID); err != nil || isAnon {
if err != nil {
m.Logger.Warnw("failed to check campaign anonymity for live stream", "error", err)
}
g.AbortWithStatus(http.StatusNotFound)
return
}
}
page := sess.getBrowserPage()
if page == nil {
// newSession() has not been called yet in the script
@@ -1199,7 +1274,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
setActivePage(p)
// Foreground the tab so Chrome doesn't throttle its rendering pipeline.
proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.Browser()) //nolint:errcheck
proto.PageBringToFront{}.Call(p) //nolint:errcheck
proto.PageBringToFront{}.Call(p) //nolint:errcheck
get, release := sess.scAcquire(p)
setFrameSource(get, release)
var pageCtx context.Context
@@ -1599,7 +1674,7 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) {
X float64 `json:"x"`
Y float64 `json:"y"`
Button string `json:"button"`
Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4)
Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4)
DeltaX float64 `json:"deltaX"`
DeltaY float64 `json:"deltaY"`
Key string `json:"key"`
@@ -1735,6 +1810,41 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) {
// saveCaptureEvent converts a remote browser capture payload to the same bundle
// format used by AITM captures and saves it as a CampaignEvent so it appears in
// the campaign timeline and can be exported to session replay tools.
// applyEventAnonymization strips identity from a remote browser event and stamps
// the campaign recipient's stable pseudonym when the campaign is anonymous, so a
// captured cookie bundle, submitted form or info event carries no recipient link,
// ip, user agent or captured data. No-op for a normal campaign.
func (m *RemoteBrowserController) applyEventAnonymization(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
event *model.CampaignEvent,
) {
if campaignID == nil || recipientID == nil {
return
}
// decide from the authoritative campaign flag, not the presence of a pseudonym.
// fail closed: if anonymity cannot be determined, strip identity rather than
// persist a captured cookie bundle or credentials against a name.
isAnon, err := m.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
m.Logger.Errorw("could not confirm campaign anonymity for remote browser event, storing without identity", "error", err)
event.Anonymize(nil)
return
}
if !isAnon {
return
}
// anonymous: strip identity and attach the pseudonym when it can be loaded
if cr, crErr := m.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil {
if aid, aerr := cr.AnonymizedID.Get(); aerr == nil {
event.Anonymize(&aid)
return
}
}
event.Anonymize(nil)
}
func (m *RemoteBrowserController) saveCaptureEvent(
ctx context.Context,
req *http.Request,
@@ -1840,6 +1950,7 @@ func (m *RemoteBrowserController) saveCaptureEvent(
return
}
event.Data = eventData
m.applyEventAnonymization(ctx, campaignID, recipientID, event)
if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil {
return
}
@@ -1883,6 +1994,7 @@ func (m *RemoteBrowserController) saveInfoEvent(
IP: vo.NewOptionalString64Must(clientIP),
UserAgent: vo.NewOptionalString255Must(userAgent),
}
m.applyEventAnonymization(ctx, campaignID, recipientID, event)
m.CampaignRepository.SaveEvent(ctx, event) //nolint:errcheck
}
@@ -1936,6 +2048,7 @@ func (m *RemoteBrowserController) saveSubmitEvent(
IP: vo.NewOptionalString64Must(clientIP),
UserAgent: vo.NewOptionalString255Must(userAgent),
}
m.applyEventAnonymization(ctx, campaignID, recipientID, event)
if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil {
return
}
+3 -2
View File
@@ -55,8 +55,9 @@ type Campaign struct {
ConstraintEndTime *string `gorm:"index;"`
SaveSubmittedData bool `gorm:"not null;default:false"`
SaveBrowserMetadata bool `gorm:"not null;default:false"`
// IsAnonymous is reserved for a campaign mode that records events without
// a recipient relation. nothing reads it and the model rejects true.
// IsAnonymous marks a campaign that records events against a stable pseudonym
// instead of the recipient, never storing identity or submitted data. The
// recipient link is kept only while the campaign is active and severed at close.
IsAnonymous bool `gorm:"not null;default:false"`
IsTest bool `gorm:"not null;default:false"`
Obfuscate bool `gorm:"not null;default:false"`
+10 -1
View File
@@ -40,12 +40,21 @@ type CampaignRecipient struct {
// self-managed
SelfManaged bool `gorm:"not null;default:false;"`
// AnonymizedID is set when the recipient has been anonymized
// AnonymizedID is the stable pseudonym for this recipient in this campaign.
// For an anonymous campaign it is assigned at materialization and stamped on
// every event so events carry no identity. For a normal campaign it is
// assigned at close by the anonymization sweep.
AnonymizedID *uuid.UUID `gorm:"type:uuid;"`
Recipient *Recipient
// A null recipientID means that the data has been anonymized
RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"`
// Position and Department are snapshotted from the recipient at
// materialization so grouped statistics survive after the recipient relation
// is severed at anonymization. Populated only for anonymous campaigns.
Position string `gorm:";"`
Department string `gorm:";"`
// NotableEventID is the most notable event for this recipient
NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"`
NotableEventID *uuid.UUID `gorm:"type:uuid;index"`
+45
View File
@@ -528,5 +528,50 @@
</div>
{{end}}
{{if .Groups}}
<!-- RESULTS BY GROUP (anonymous campaigns) -->
<div class="page">
<div class="page-header">
<span class="section-label">Results by {{.GroupsBy}}</span>
{{if .CompanyName}}<span class="company">{{.CompanyName}}</span>{{end}}
</div>
<div class="section-title">Results by {{.GroupsBy}}</div>
<table>
<thead>
<tr>
<th>{{.GroupsBy}}</th>
<th style="width:70px;text-align:right">Targets</th>
<th style="width:96px;text-align:right">Visited</th>
<th style="width:96px;text-align:right">Submitted</th>
<th style="width:96px;text-align:right">Reported</th>
</tr>
</thead>
<tbody>
{{range .Groups}}
<tr>
<td>{{.Group}}</td>
<td style="text-align:right">{{.Total}}</td>
{{if .Suppressed}}
<td colspan="3" style="text-align:center;color:rgba(249,250,251,0.35)">Hidden (group too small)</td>
{{else}}
<td style="text-align:right">{{if lt .Clicked 0}}<span style="color:rgba(249,250,251,0.35)"></span>{{else}}<span style="color:#f59e0b;font-weight:600">{{.Clicked}}</span> <span style="color:rgba(249,250,251,0.45)">({{.ClickedPercent}}%)</span>{{end}}</td>
<td style="text-align:right">{{if lt .Submitted 0}}<span style="color:rgba(249,250,251,0.35)"></span>{{else}}<span style="color:#ef4444;font-weight:600">{{.Submitted}}</span> <span style="color:rgba(249,250,251,0.45)">({{.SubmittedPercent}}%)</span>{{end}}</td>
<td style="text-align:right">{{if lt .Reported 0}}<span style="color:rgba(249,250,251,0.35)"></span>{{else}}<span style="color:#10b981;font-weight:600">{{.Reported}}</span> <span style="color:rgba(249,250,251,0.45)">({{.ReportedPercent}}%)</span>{{end}}</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
<p style="margin-top:12px;font-size:11px;color:rgba(249,250,251,0.45)">A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.</p>
<div class="page-footer">
<span>Phishing Simulation</span>
<span>{{.CampaignName}}</span>
</div>
</div>
{{end}}
</body>
</html>
@@ -509,5 +509,50 @@
</div>
{{end}}
{{if .Groups}}
<!-- RESULTS BY GROUP -->
<div class="page">
<div class="page-header">
<span class="section-label">Results by {{.GroupsBy}}</span>
{{if .CompanyName}}<span class="company">{{.CompanyName}}</span>{{end}}
</div>
<div class="section-title">Results by {{.GroupsBy}}</div>
<table>
<thead>
<tr>
<th>{{.GroupsBy}}</th>
<th style="width:70px;text-align:right">Targets</th>
<th style="width:96px;text-align:right">Visited</th>
<th style="width:96px;text-align:right">Started</th>
<th style="width:96px;text-align:right">Completed</th>
</tr>
</thead>
<tbody>
{{range .Groups}}
<tr>
<td>{{.Group}}</td>
<td style="text-align:right">{{.Total}}</td>
{{if .Suppressed}}
<td colspan="3" style="text-align:center;color:rgba(249,250,251,0.35)">Hidden (group too small)</td>
{{else}}
<td style="text-align:right">{{if lt .Clicked 0}}<span style="color:rgba(249,250,251,0.35)"></span>{{else}}<span style="color:#f59e0b;font-weight:600">{{.Clicked}}</span> <span style="color:rgba(249,250,251,0.45)">({{.ClickedPercent}}%)</span>{{end}}</td>
<td style="text-align:right">{{if lt .TrainingStarted 0}}<span style="color:rgba(249,250,251,0.35)"></span>{{else}}<span style="color:#5b93e6;font-weight:600">{{.TrainingStarted}}</span> <span style="color:rgba(249,250,251,0.45)">({{.TrainingStartedPercent}}%)</span>{{end}}</td>
<td style="text-align:right">{{if lt .TrainingCompleted 0}}<span style="color:rgba(249,250,251,0.35)"></span>{{else}}<span style="color:#10b981;font-weight:600">{{.TrainingCompleted}}</span> <span style="color:rgba(249,250,251,0.45)">({{.TrainingCompletedPercent}}%)</span>{{end}}</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
<p style="margin-top:12px;font-size:11px;color:rgba(249,250,251,0.45)">A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.</p>
<div class="page-footer">
<span>Awareness Training</span>
<span>{{.CampaignName}}</span>
</div>
</div>
{{end}}
</body>
</html>
+13 -3
View File
@@ -46,8 +46,10 @@ type Campaign struct {
SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"`
SaveBrowserMetadata nullable.Nullable[bool] `json:"saveBrowserMetadata"`
// IsAnonymous is reserved for a campaign mode that records events without
// a recipient relation. no code acts on it and Validate rejects true.
// IsAnonymous marks a campaign that records events against a stable pseudonym
// instead of the recipient, never storing identity or submitted data. Validate
// forbids enabling data collection alongside it, and it cannot be changed once
// recipients exist.
IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"`
IsTest nullable.Nullable[bool] `json:"isTest"`
Obfuscate nullable.Nullable[bool] `json:"obfuscate"`
@@ -147,8 +149,16 @@ func (c *Campaign) Validate() error {
if err := validate.NullableFieldRequired("sortOrder", c.SortOrder); err != nil {
return err
}
// an anonymous campaign must never capture identifying data. enforce it here
// so a crafted request cannot enable collection on an anonymous campaign,
// independent of what the UI sends.
if v, err := c.IsAnonymous.Get(); err == nil && v {
return validate.WrapErrorWithField(errors.New("anonymous campaigns are not supported"), "isAnonymous")
if sd, err := c.SaveSubmittedData.Get(); err == nil && sd {
return validate.WrapErrorWithField(errors.New("submitted data cannot be saved on an anonymous campaign"), "saveSubmittedData")
}
if bm, err := c.SaveBrowserMetadata.Get(); err == nil && bm {
return validate.WrapErrorWithField(errors.New("browser metadata cannot be saved on an anonymous campaign"), "saveBrowserMetadata")
}
}
// if a start or end is set, then end must be equal or after the start
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() || (c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull()) {
+27 -9
View File
@@ -8,17 +8,35 @@ import (
)
type CampaignEvent struct {
ID *uuid.UUID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
CampaignID *uuid.UUID `json:"campaignID"`
Campaign *Campaign `json:"campaign,omitempty"`
IP *vo.OptionalString64 `json:"ip"`
UserAgent *vo.OptionalString255 `json:"userAgent"`
Data *vo.OptionalString1MB `json:"data"`
Metadata *vo.OptionalString1MB `json:"metadata"`
AnonymizedID *uuid.UUID `json:"anonymizedID"`
ID *uuid.UUID `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
CampaignID *uuid.UUID `json:"campaignID"`
Campaign *Campaign `json:"campaign,omitempty"`
IP *vo.OptionalString64 `json:"ip"`
UserAgent *vo.OptionalString255 `json:"userAgent"`
Data *vo.OptionalString1MB `json:"data"`
Metadata *vo.OptionalString1MB `json:"metadata"`
// AnonymizedID is the pseudonym that links an anonymous campaign's events to one
// another and to the recipient row. It is an internal join key and must never be
// serialized: exposing it would let a reader group a pseudonym's events into a
// per-person journey and re-identify it by matching the send event to a
// recipient. Stats and grouping read it server-side, off the database.
AnonymizedID *uuid.UUID `json:"-"`
// if null the recipient has been anonymized
RecipientID *uuid.UUID `json:"recipientID"`
Recipient *Recipient `json:"recipient,omitempty"`
EventID *uuid.UUID `json:"eventID"`
}
// Anonymize strips identity from an event and stamps the stable pseudonym in its
// place. The event stays countable and groupable through the pseudonym but
// carries no recipient link, ip, user agent, submitted data or metadata. Used by
// anonymous campaigns, where every event is written this way from the outset.
func (e *CampaignEvent) Anonymize(anonymizedID *uuid.UUID) {
e.RecipientID = nil
e.AnonymizedID = anonymizedID
e.IP = vo.NewEmptyOptionalString64()
e.UserAgent = vo.NewEmptyOptionalString255()
e.Data = vo.NewEmptyOptionalString1MB()
e.Metadata = vo.NewEmptyOptionalString1MB()
}
+45 -12
View File
@@ -22,12 +22,25 @@ type CampaignRecipient struct {
LastAttemptAt nullable.Nullable[time.Time] `json:"lastAttemptAt"`
SentAt nullable.Nullable[time.Time] `json:"sentAt"`
SelfManaged nullable.Nullable[bool] `json:"selfManaged"`
AnonymizedID nullable.Nullable[uuid.UUID] `json:"anonymizedID"`
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"`
Campaign *Campaign `json:"campaign"`
// AnonymizedID is the stable pseudonym. It is an internal join key that ties the
// pseudonym to identity while the recipient row still carries a name, so it must
// never be serialized: a reader with it could group a pseudonym's events into a
// per-person journey and re-identify them. Anonymization reads it server-side.
AnonymizedID nullable.Nullable[uuid.UUID] `json:"-"`
// Sent is a coarse, timing-free send indicator used in place of the exact
// SendAt/SentAt for anonymous campaigns, which are withheld so per-recipient
// timing cannot be matched against the anonymized event stream.
Sent bool `json:"sent"`
CampaignID nullable.Nullable[uuid.UUID] `json:"campaignID"`
Campaign *Campaign `json:"campaign"`
// null recipientID means that the data has been anonymized
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"`
Recipient *Recipient `json:"recipient"`
RecipientID nullable.Nullable[uuid.UUID] `json:"recipientID"`
Recipient *Recipient `json:"recipient"`
// Position and Department are snapshotted from the recipient at
// materialization for anonymous campaigns so grouped statistics survive after
// the recipient relation is severed.
Position nullable.Nullable[string] `json:"position"`
Department nullable.Nullable[string] `json:"department"`
NotableEventID nullable.Nullable[uuid.UUID] `json:"notableEventID"`
NotableEventName string `json:"notableEventName"`
// LureCode is the identifier in the lure URL, stored and matched byte for
@@ -37,19 +50,21 @@ type CampaignRecipient struct {
LureCodeCustom nullable.Nullable[bool] `json:"lureCodeCustom"`
}
// Validate validates the campaign recipient
// Validate validates the campaign recipient.
//
// A row must carry a campaign and at least one of a recipient id or a pseudonym.
// Both may be set at once: an anonymous campaign holds a recipient id to send and
// resolve the lure while the pseudonym is stamped on its events, and the recipient
// id is severed only at anonymization.
func (c *CampaignRecipient) Validate() error {
if err := validate.NullableFieldRequired("campaignID", c.CampaignID); err != nil {
return err
}
anonymizedAtErr := validate.NullableFieldRequired("anonymizedID", c.AnonymizedID)
anonymizedIDErr := validate.NullableFieldRequired("anonymizedID", c.AnonymizedID)
recipientIDErr := validate.NullableFieldRequired("recipientID", c.RecipientID)
if anonymizedAtErr == nil && recipientIDErr == nil {
return recipientIDErr
}
if anonymizedAtErr != nil && recipientIDErr != nil {
if anonymizedIDErr != nil && recipientIDErr != nil {
return validate.WrapErrorWithField(
errors.New("AnonymizedID can not be set with recipientID"),
errors.New("a campaign recipient must have a recipientID or an anonymizedID"),
"recipientID",
)
}
@@ -103,6 +118,24 @@ func (c *CampaignRecipient) ToDBMap() map[string]any {
m["recipient_id"] = v
}
}
if c.AnonymizedID.IsSpecified() {
m["anonymized_id"] = nil
if v, err := c.AnonymizedID.Get(); err == nil {
m["anonymized_id"] = v
}
}
if c.Position.IsSpecified() {
m["position"] = nil
if v, err := c.Position.Get(); err == nil {
m["position"] = v
}
}
if c.Department.IsSpecified() {
m["department"] = nil
if v, err := c.Department.Get(); err == nil {
m["department"] = v
}
}
if c.NotableEventID.IsSpecified() {
m["notable_event_id"] = nil
if v, err := c.NotableEventID.Get(); err == nil {
+55 -13
View File
@@ -11,12 +11,12 @@ import (
// ReportTemplate is a report template
type ReportTemplate struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
Content nullable.Nullable[vo.OptionalString1MB] `json:"content"`
IsTraining nullable.Nullable[bool] `json:"isTraining"`
IsTraining nullable.Nullable[bool] `json:"isTraining"`
Company *Company `json:"-"`
}
@@ -74,24 +74,24 @@ type ReportData struct {
ResultSubmitted int64
ResultReported int64
// Formatted percentages (e.g. "45.2") ready to use directly in templates
// Formatted percentages (e.g. "45.2"), ready to use directly in templates
ResultClickedPercent string
ResultSubmittedPercent string
ResultReportedPercent string
// Float percentages for custom formatting with {{printf "%.1f" .ClickRate}}
SentRate float64
OpenRate float64
ClickRate float64
SubmitRate float64
ReportRate float64
SentRate float64
OpenRate float64
ClickRate float64
SubmitRate float64
ReportRate float64
// Relative conversion rates funnel step-to-step (formatted strings like "45.2")
// Relative conversion rates, funnel step to step (formatted strings like "45.2")
OpenedOfSent string // EmailsOpened / EmailsSent
ClickedOfOpened string // ResultClicked / EmailsOpened
SubmittedOfClicked string // ResultSubmitted / ResultClicked
// Awareness training funnel populated for training campaigns, zero otherwise.
// Awareness training funnel, populated for training campaigns, zero otherwise.
// The training report template renders these in place of the phishing outcomes.
IsTraining bool
TrainingStarted int64
@@ -103,8 +103,35 @@ type ReportData struct {
StartedOfOpened string // TrainingStarted / EmailsOpened
CompletedOfStarted string // TrainingCompleted / TrainingStarted
// Per-recipient detail empty for anonymous or anonymized campaigns
// Per recipient detail, empty for anonymous or anonymized campaigns
Recipients []ReportRecipient
// Grouped outcome breakdown. Groups is the default dimension (Department when
// present, else Position), named by GroupsBy; DepartmentGroups and PositionGroups
// expose each dimension explicitly.
GroupsBy string
Groups []ReportGroupStat
DepartmentGroups []ReportGroupStat
PositionGroups []ReportGroupStat
}
// ReportGroupStat is one row of the report's grouped outcome breakdown, with
// percentages pre-formatted as "45" strings ready for the template. Suppressed
// hides the outcome counts for a group below the anonymity floor.
type ReportGroupStat struct {
Group string
Total int
Clicked int
ClickedPercent string
Submitted int
SubmittedPercent string
Reported int
ReportedPercent string
TrainingStarted int
TrainingStartedPercent string
TrainingCompleted int
TrainingCompletedPercent string
Suppressed bool
}
// ReportRecipient holds per-recipient result data for the recipient detail table
@@ -120,3 +147,18 @@ type ReportRecipient struct {
TrainingStarted bool
TrainingCompleted bool
}
// CampaignGroupStat holds an aggregate outcome count for one group value, such
// as one position or one department. It carries no identity, only counts, so it
// is safe to show for an anonymous campaign. Suppressed is true when the group is
// smaller than the anonymity floor and its counts are withheld.
type CampaignGroupStat struct {
Group string `json:"group"`
Total int `json:"total"`
Clicked int `json:"clicked"`
Submitted int `json:"submitted"`
Reported int `json:"reported"`
TrainingStarted int `json:"trainingStarted"`
TrainingCompleted int `json:"trainingCompleted"`
Suppressed bool `json:"suppressed"`
}
+53 -1
View File
@@ -3699,6 +3699,7 @@ func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, ca
UserAgent: vo.NewOptionalString255Must(originalUserAgent),
}
m.applyEventAnonymization(ctx, campaign, session.CampaignID, session.RecipientID, event)
err = m.CampaignRepository.SaveEvent(ctx, event)
if err != nil {
m.logger.Errorw("failed to create campaign info event", "error", err)
@@ -3770,6 +3771,7 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession,
UserAgent: vo.NewOptionalString255Must(originalUserAgent),
}
m.applyEventAnonymization(ctx, campaign, session.CampaignID, session.RecipientID, event)
err = m.CampaignRepository.SaveEvent(ctx, event)
if err != nil {
m.logger.Errorw("failed to create campaign submit event", "error", err)
@@ -4317,7 +4319,53 @@ func (m *ProxyHandler) createStatusResponse(statusCode int) *http.Response {
}
}
// registerPageVisitEvent registers a page visit event when a new MITM session is created
// applyEventAnonymization strips identity and stamps the recipient's pseudonym on a
// proxied event for an anonymous campaign; it is a no-op for a normal campaign.
func (m *ProxyHandler) applyEventAnonymization(
ctx context.Context,
campaign *model.Campaign,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
event *model.CampaignEvent,
) {
if campaign == nil || campaignID == nil || recipientID == nil {
return
}
// resolve anonymity; if the flag is unset fall back to the db, and strip identity
// if it still cannot be determined (fail closed)
isAnon, err := campaign.IsAnonymous.Get()
if err != nil {
isAnon, err = m.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
m.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err)
event.Anonymize(nil)
return
}
}
if !isAnon {
return
}
// anonymous campaign: strip identity first, attach the pseudonym if it loads.
// a lookup failure leaves the event uncounted but never leaks identity.
cr, err := m.CampaignRecipientRepository.GetByCampaignAndRecipientID(
ctx,
campaignID,
recipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
m.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err)
event.Anonymize(nil)
return
}
aid, err := cr.AnonymizedID.Get()
if err != nil {
event.Anonymize(nil)
return
}
event.Anonymize(&aid)
}
func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *service.ProxySession) {
if session.CampaignRecipientID == nil || session.CampaignID == nil || session.RecipientID == nil {
return
@@ -4386,6 +4434,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic
}
// save the synthetic message read event
m.applyEventAnonymization(ctx, session.Campaign, session.CampaignID, session.RecipientID, syntheticReadEvent)
err = m.CampaignRepository.SaveEvent(ctx, syntheticReadEvent)
if err != nil {
m.logger.Errorw("failed to save synthetic message read event",
@@ -4450,6 +4499,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic
}
// save the visit event
m.applyEventAnonymization(ctx, session.Campaign, session.CampaignID, session.RecipientID, visitEvent)
err = m.CampaignRepository.SaveEvent(ctx, visitEvent)
if err != nil {
m.logger.Errorw("failed to save MITM page visit event",
@@ -5072,6 +5122,7 @@ func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCt
Metadata: metadata,
}
m.applyEventAnonymization(req.Context(), campaign, campaignID, recipientID, event)
err := m.CampaignRepository.SaveEvent(req.Context(), event)
if err != nil {
m.logger.Errorw("failed to save deny page visit event", "error", err)
@@ -5131,6 +5182,7 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re
Metadata: metadata,
}
m.applyEventAnonymization(req.Context(), campaign, campaignID, recipientID, event)
err := m.CampaignRepository.SaveEvent(req.Context(), event)
if err != nil {
m.logger.Errorw("failed to save evasion page visit event", "error", err)
+261 -25
View File
@@ -901,20 +901,16 @@ func (r *Campaign) GetResultStats(
) (*model.CampaignResultView, error) {
stats := &model.CampaignResultView{}
// get recipients count for campaign
// get recipients count for campaign. each campaign_recipients row is one person
// and carries a recipient_id, an anonymized_id, or both (an anonymous campaign
// holds both while live so it can still send). counting rows avoids the double
// count a UNION of the two id columns would produce for a live anonymous campaign.
res := r.DB.Raw(`
SELECT COUNT(*) FROM (
SELECT DISTINCT recipient_id
FROM campaign_recipients
WHERE campaign_id = ?
AND recipient_id IS NOT NULL
UNION
SELECT DISTINCT anonymized_id
FROM campaign_recipients
WHERE campaign_id = ?
AND anonymized_id IS NOT NULL
) as unique_ids
`, campaignID, campaignID).Scan(&stats.Recipients)
SELECT COUNT(*)
FROM campaign_recipients
WHERE campaign_id = ?
AND (recipient_id IS NOT NULL OR anonymized_id IS NOT NULL)
`, campaignID).Scan(&stats.Recipients)
if res.Error != nil {
return nil, res.Error
@@ -1322,6 +1318,108 @@ func (r *Campaign) GetNameByID(
return dbCampaign.Name, nil
}
// IsAnonymousByID reads only the anonymity flag for a campaign.
func (r *Campaign) IsAnonymousByID(
ctx context.Context,
id *uuid.UUID,
) (bool, error) {
var dbCampaign database.Campaign
res := r.DB.
Model(&database.Campaign{}).
Select("is_anonymous").
Where("id = ?", id).
First(&dbCampaign)
if res.Error != nil {
return false, res.Error
}
return dbCampaign.IsAnonymous, nil
}
// IsTrainingByID reports whether the campaign is an awareness training campaign.
func (r *Campaign) IsTrainingByID(
ctx context.Context,
id *uuid.UUID,
) (bool, error) {
var dbCampaign database.Campaign
res := r.DB.
Model(&database.Campaign{}).
Select("is_training").
Where("id = ?", id).
First(&dbCampaign)
if res.Error != nil {
return false, res.Error
}
return dbCampaign.IsTraining, nil
}
// IsAnonymizedByID reports whether the campaign has been anonymized after the fact,
// i.e. its AnonymizedAt timestamp is set.
func (r *Campaign) IsAnonymizedByID(
ctx context.Context,
id *uuid.UUID,
) (bool, error) {
var dbCampaign database.Campaign
res := r.DB.
Model(&database.Campaign{}).
Select("anonymized_at").
Where("id = ?", id).
First(&dbCampaign)
if res.Error != nil {
return false, res.Error
}
return dbCampaign.AnonymizedAt != nil, nil
}
// IsAnonymousByCampaignRecipientID reports whether the campaign owning a campaign
// recipient is anonymous. found is false when the id matches no campaign recipient.
func (r *Campaign) IsAnonymousByCampaignRecipientID(
ctx context.Context,
campaignRecipientID *uuid.UUID,
) (isAnon bool, found bool, err error) {
var out []bool
res := r.DB.WithContext(ctx).Raw(`
SELECT c.is_anonymous
FROM campaign_recipients cr
JOIN campaigns c ON c.id = cr.campaign_id
WHERE cr.id = ?
LIMIT 1`, campaignRecipientID).Scan(&out)
if res.Error != nil {
return false, false, res.Error
}
if len(out) == 0 {
return false, false, nil
}
return out[0], true, nil
}
// HasGroupDataByCampaignID reports whether any recipient carries a position or
// department, i.e. whether a grouped breakdown has anything to show.
func (r *Campaign) HasGroupDataByCampaignID(
ctx context.Context,
id *uuid.UUID,
) (bool, error) {
// check the snapshot columns and, since they are set only for anonymous
// campaigns, the recipient's own position/department too
var count int64
res := r.DB.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM (
SELECT 1 FROM campaign_recipients cr
LEFT JOIN recipients r ON r.id = cr.recipient_id
WHERE cr.campaign_id = ? AND (
cr.position <> '' OR cr.department <> ''
OR COALESCE(r.position, '') <> '' OR COALESCE(r.department, '') <> ''
)
LIMIT 1
) AS x`, id).Scan(&count)
if res.Error != nil {
return false, res.Error
}
return count > 0, nil
}
// GetByNameAndCompanyID gets a campaign by name and company id
func (r *Campaign) GetByNameAndCompanyID(
ctx context.Context,
@@ -1413,9 +1511,16 @@ func (r *Campaign) GetReadyToAnonymize(
if err != nil {
return result, errs.Wrap(err)
}
// a campaign is ready to anonymize when its scheduled anonymize_at has passed,
// or when it is anonymous and closed: an anonymous campaign always severs its
// recipient relation at close, and this is the fallback that guarantees it even
// if the inline anonymize at close time failed. either way anonymized_at must be
// unset so an already-anonymized campaign is never reprocessed.
var dbCampaigns []database.Campaign
now := utils.NowRFC3339UTC()
res := db.
Where("anonymize_at <= ? AND anonymized_at IS NULL", utils.NowRFC3339UTC()).
Where("anonymized_at IS NULL").
Where("(anonymize_at <= ?) OR (is_anonymous = ? AND closed_at IS NOT NULL)", now, true).
Find(&dbCampaigns)
if res.Error != nil {
return result, res.Error
@@ -1531,6 +1636,9 @@ func (r *Campaign) SaveEvent(
if campaignEvent.RecipientID != nil {
row["recipient_id"] = campaignEvent.RecipientID.String()
}
if campaignEvent.AnonymizedID != nil {
row["anonymized_id"] = campaignEvent.AnonymizedID.String()
}
AddTimestamps(row)
res := r.DB.Model(&database.CampaignEvent{}).Create(row)
if res.Error != nil {
@@ -1589,6 +1697,25 @@ func (r *Campaign) HasEvent(
return count > 0, nil
}
// HasEventByAnonymizedID reports whether an event of the given type already exists
// for a pseudonym in a campaign. Used to deduplicate events on anonymous campaigns,
// whose events carry no recipient id.
func (r *Campaign) HasEventByAnonymizedID(
ctx context.Context,
campaignID *uuid.UUID,
anonymizedID *uuid.UUID,
eventID *uuid.UUID,
) (bool, error) {
var count int64
res := r.DB.Model(&database.CampaignEvent{}).
Where("campaign_id = ? AND event_id = ? AND anonymized_id = ?", campaignID, eventID, anonymizedID).
Count(&count)
if res.Error != nil {
return false, res.Error
}
return count > 0, nil
}
// SetLureSettingsByID writes the lure URL settings taken from the campaign
// template. The only path that writes them, because a create or update request
// carries whatever a caller put in those fields and they must come from the
@@ -2398,19 +2525,19 @@ func (r *Campaign) DeleteCampaignStatsByID(ctx context.Context, statsID *uuid.UU
// reportRecipientRow is the scan target for GetReportRecipients
type reportRecipientRow struct {
FirstName string `gorm:"column:first_name"`
LastName string `gorm:"column:last_name"`
Email string `gorm:"column:email"`
Department string `gorm:"column:department"`
Position string `gorm:"column:position"`
ClickedLink bool `gorm:"column:clicked_link"`
SubmittedData bool `gorm:"column:submitted_data"`
Reported bool `gorm:"column:reported"`
TrainingStarted bool `gorm:"column:training_started"`
TrainingCompleted bool `gorm:"column:training_completed"`
FirstName string `gorm:"column:first_name"`
LastName string `gorm:"column:last_name"`
Email string `gorm:"column:email"`
Department string `gorm:"column:department"`
Position string `gorm:"column:position"`
ClickedLink bool `gorm:"column:clicked_link"`
SubmittedData bool `gorm:"column:submitted_data"`
Reported bool `gorm:"column:reported"`
TrainingStarted bool `gorm:"column:training_started"`
TrainingCompleted bool `gorm:"column:training_completed"`
}
// GetReportRecipients returns per-recipient click/submit/reported results for a campaign.
// GetReportRecipients returns per recipient click/submit/reported results for a campaign.
// Only non-anonymized recipients (recipient_id IS NOT NULL) are included.
func (r *Campaign) GetReportRecipients(
ctx context.Context,
@@ -2501,3 +2628,112 @@ func (r *Campaign) GetReportRecipients(
}
return result, nil
}
// groupedStatColumns is the fixed allowlist of snapshot columns a grouped stats
// query may group on. Never group on a raw column taken from user input.
var groupedStatColumns = map[string]bool{
"position": true,
"department": true,
}
// GetGroupedResultStats returns outcome counts grouped by a recipient attribute
// snapshotted on the campaign recipient, joining events to recipients by the
// stable pseudonym so it works for an anonymous campaign both while live and after
// the recipient relation is severed. Each unique pseudonym is counted once per
// outcome. Only anonymous campaigns snapshot the grouping columns, so this returns
// empty for a campaign that has none.
func (r *Campaign) GetGroupedResultStats(
ctx context.Context,
campaignID *uuid.UUID,
groupColumn string,
) ([]model.CampaignGroupStat, error) {
if !groupedStatColumns[groupColumn] {
return nil, fmt.Errorf("invalid group column: %s", groupColumn)
}
beforeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED]
pageID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED]
afterID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED]
submitID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA]
reportID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
startedID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_STARTED]
completedID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_TRAINING_COMPLETED]
type groupRow struct {
Grp string
Total int
Clicked int
Submitted int
Reported int
TrainingStarted int
TrainingCompleted int
}
var rows []groupRow
// the person key is the pseudonym when present, else the recipient id, so counts
// work for normal, anonymous and anonymized campaigns; the group value is the
// snapshot, falling back to the recipient's attribute. groupColumn is allowlisted
// above, so its interpolation is safe.
query := fmt.Sprintf(`
SELECT
COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s) AS grp,
COUNT(DISTINCT COALESCE(cr.anonymized_id, cr.recipient_id)) AS total,
COUNT(DISTINCT CASE WHEN clicked.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS clicked,
COUNT(DISTINCT CASE WHEN submitted.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS submitted,
COUNT(DISTINCT CASE WHEN reported.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS reported,
COUNT(DISTINCT CASE WHEN started.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_started,
COUNT(DISTINCT CASE WHEN completed.pk IS NOT NULL THEN COALESCE(cr.anonymized_id, cr.recipient_id) END) AS training_completed
FROM campaign_recipients cr
LEFT JOIN recipients r ON r.id = cr.recipient_id
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id IN (?, ?, ?)
) AS clicked ON clicked.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS submitted ON submitted.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS reported ON reported.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS started ON started.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
LEFT JOIN (
SELECT DISTINCT COALESCE(anonymized_id, recipient_id) AS pk FROM campaign_events
WHERE campaign_id = ? AND COALESCE(anonymized_id, recipient_id) IS NOT NULL AND event_id = ?
) AS completed ON completed.pk = COALESCE(cr.anonymized_id, cr.recipient_id)
WHERE cr.campaign_id = ? AND COALESCE(cr.anonymized_id, cr.recipient_id) IS NOT NULL
GROUP BY COALESCE(NULLIF(cr.%[1]s, ''), r.%[1]s)
ORDER BY total DESC
`, groupColumn)
res := r.DB.WithContext(ctx).Raw(query,
campaignID, beforeID, pageID, afterID,
campaignID, submitID,
campaignID, reportID,
campaignID, startedID,
campaignID, completedID,
campaignID,
).Scan(&rows)
if res.Error != nil {
return nil, res.Error
}
result := make([]model.CampaignGroupStat, 0, len(rows))
for _, row := range rows {
grp := row.Grp
if grp == "" {
grp = "(unspecified)"
}
result = append(result, model.CampaignGroupStat{
Group: grp,
Total: row.Total,
Clicked: row.Clicked,
Submitted: row.Submitted,
Reported: row.Reported,
TrainingStarted: row.TrainingStarted,
TrainingCompleted: row.TrainingCompleted,
})
}
return result, nil
}
+31 -18
View File
@@ -714,33 +714,42 @@ func (c *CampaignRecipient) UpdateNotableEventByID(
return res.Error
}
// Anonymize adds an anonymized id to a campaign recipient
// Anonymize ensures a campaign recipient carries a pseudonym and releases its
// lure code. An existing pseudonym is never overwritten: an anonymous campaign
// assigns a stable one at materialization and its events already carry it, so a
// later anonymize (at close, or when the recipient is deleted) must keep it or the
// events would be orphaned from the recipient row.
func (r *CampaignRecipient) Anonymize(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
anonymizedID *uuid.UUID,
) error {
// releasing here stops the recipient's lure link resolving and hands the code
// back for reuse
row := map[string]interface{}{
"anonymized_id": anonymizedID.String(),
"lure_code": nil,
}
AddUpdatedAt(row)
db := r.DB.Model(&database.CampaignRecipient{})
// if campaignID is nil, anonymize across all campaigns (e.g., when deleting recipient)
// otherwise, only anonymize for the specific campaign
if campaignID != nil {
db = db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID)
} else {
db = db.Where("recipient_id = ?", recipientID)
// where clause: a specific campaign, or all campaigns for the recipient (used
// when deleting the recipient).
scope := func(db *gorm.DB) *gorm.DB {
if campaignID != nil {
return db.Where("campaign_id = ? AND recipient_id = ?", campaignID, recipientID)
}
return db.Where("recipient_id = ?", recipientID)
}
res := db.Updates(row)
// release the lure code so the link stops resolving and the code can be reused.
lureRow := map[string]interface{}{"lure_code": nil}
AddUpdatedAt(lureRow)
if res := scope(r.DB.Model(&database.CampaignRecipient{})).Updates(lureRow); res.Error != nil {
return res.Error
}
if res.Error != nil {
// assign the pseudonym only where none exists yet. an anonymous campaign already
// has a stable one whose events reference it, so overwriting would orphan them.
// a plain assignment (not COALESCE) keeps the uuid column type unambiguous on
// both sqlite and postgres.
idRow := map[string]interface{}{"anonymized_id": anonymizedID.String()}
AddUpdatedAt(idRow)
if res := scope(r.DB.Model(&database.CampaignRecipient{})).
Where("anonymized_id IS NULL").
Updates(idRow); res.Error != nil {
return res.Error
}
return nil
@@ -895,6 +904,8 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
if row.LureCode != nil {
lureCode = nullable.NewNullableWithValue(*row.LureCode)
}
position := nullable.NewNullableWithValue(row.Position)
department := nullable.NewNullableWithValue(row.Department)
return &model.CampaignRecipient{
ID: id,
CancelledAt: cancelledAt,
@@ -907,6 +918,8 @@ func ToCampaignRecipient(row *database.CampaignRecipient) (*model.CampaignRecipi
AnonymizedID: anonymizedID,
RecipientID: recipientID,
Recipient: recipient,
Position: position,
Department: department,
NotableEventID: notableEventID,
NotableEventName: notableEventName,
LureCode: lureCode,
+535 -90
View File
@@ -414,6 +414,61 @@ func isLureCodeConflict(err error) bool {
// insertScheduledRecipient inserts one scheduled recipient, redrawing its code if
// it was claimed between allocation and insert. The loop has no transaction to
// roll back, so a losing race would otherwise abort a half written schedule.
// campaignAnonymous reports whether a campaign is anonymous, failing closed: if the
// flag cannot be read it returns true so identity is protected rather than exposed.
func campaignAnonymous(campaign *model.Campaign) bool {
if campaign == nil {
return true
}
isAnon, err := campaign.IsAnonymous.Get()
if err != nil {
return true
}
return isAnon
}
// applyAnonymousSnapshot assigns the stable random pseudonym and snapshots the
// grouping attributes onto a campaign recipient for an anonymous campaign; it is a
// no-op for a normal campaign.
func (c *Campaign) applyAnonymousSnapshot(
campaign *model.Campaign,
campaignRecipient *model.CampaignRecipient,
recipient *model.Recipient,
) {
if !campaignAnonymous(campaign) {
return
}
campaignRecipient.AnonymizedID = nullable.NewNullableWithValue(uuid.New())
position := ""
if v, err := recipient.Position.Get(); err == nil {
position = v.String()
}
department := ""
if v, err := recipient.Department.Get(); err == nil {
department = v.String()
}
campaignRecipient.Position = nullable.NewNullableWithValue(position)
campaignRecipient.Department = nullable.NewNullableWithValue(department)
}
// anonymizeEventForRecipient strips identity from an event and stamps the recipient's
// pseudonym. It fails closed: with a pseudonym it always strips and stamps it;
// without one it strips (uncounted) only for an anonymous campaign, and is a no-op
// for a normal campaign.
func anonymizeEventForRecipient(isAnonymous bool, campaignRecipient *model.CampaignRecipient, event *model.CampaignEvent) {
// a pseudonym is present only on anonymous campaigns: strip identity and stamp it
if campaignRecipient != nil {
if aid, err := campaignRecipient.AnonymizedID.Get(); err == nil {
event.Anonymize(&aid)
return
}
}
// no pseudonym: strip (uncounted) only when the campaign is anonymous
if isAnonymous {
event.Anonymize(nil)
}
}
func (c *Campaign) insertScheduledRecipient(
ctx context.Context,
allocator *lureCodeAllocator,
@@ -703,6 +758,7 @@ func (c *Campaign) schedule(
CampaignID: nullable.NewNullableWithValue(campaignID),
SelfManaged: nullable.NewNullableWithValue(true),
}
c.applyAnonymousSnapshot(campaign, campaignRecipients[i], recipient)
if err := allocator.applyTo(campaignRecipients[i]); err != nil {
c.Logger.Errorw("failed to allocate lure code", "error", err)
return err
@@ -781,6 +837,7 @@ func (c *Campaign) schedule(
SendAt: nullable.NewNullableWithValue(jitteredStartAt),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
c.applyAnonymousSnapshot(campaign, campaignRecipient, recipients[0])
if err := allocator.applyTo(campaignRecipient); err != nil {
c.Logger.Errorw("failed to allocate lure code", "error", err)
return err
@@ -862,6 +919,7 @@ func (c *Campaign) schedule(
SendAt: nullable.NewNullableWithValue(jitteredTime),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
c.applyAnonymousSnapshot(campaign, campaignRecipient, recipient)
if err := allocator.applyTo(campaignRecipient); err != nil {
c.Logger.Errorw("failed to allocate lure code", "error", err)
return err
@@ -912,6 +970,7 @@ func (c *Campaign) schedule(
SendAt: nullable.NewNullableWithValue(jitteredSentAt),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
c.applyAnonymousSnapshot(campaign, campaignRecipients[i], recipient)
if err := allocator.applyTo(campaignRecipients[i]); err != nil {
c.Logger.Errorw("failed to allocate lure code", "error", err)
return err
@@ -1268,6 +1327,158 @@ func (c *Campaign) GetResultStats(
return stats, nil
}
// anonymityGroupFloor is the smallest group whose outcome counts are shown; groups
// below it are merged so a lone recipient's outcome cannot be read.
const anonymityGroupFloor = 3
// OTHER_GROUP_LABEL is the bucket name for merged small groups.
const OTHER_GROUP_LABEL = "Other (small groups)"
// GetGroupedResultStats returns outcome counts grouped by a recipient attribute,
// suppressing any group smaller than the anonymity floor. Only anonymous campaigns
// snapshot the grouping attributes, so a normal campaign returns an empty result.
func (c *Campaign) GetGroupedResultStats(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
groupColumn string,
) ([]model.CampaignGroupStat, error) {
ae := NewAuditEvent("Campaign.GetGroupedResultStats", session)
ae.Details["campaignId"] = campaignID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// grouping attributes are snapshotted only for anonymous campaigns. a closed
// normal campaign has pseudonyms but no snapshot, which would surface as a single
// unlabeled group, so restrict this to anonymous campaigns.
// shown for anonymous campaigns (in place of hidden per recipient outcomes) and
// for training campaigns (the started/completed breakdown).
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
c.Logger.Errorw("failed to check campaign anonymity for grouped stats", "error", err)
return nil, errs.Wrap(err)
}
isTraining, err := c.CampaignRepository.IsTrainingByID(ctx, campaignID)
if err != nil {
c.Logger.Errorw("failed to check campaign training flag for grouped stats", "error", err)
return nil, errs.Wrap(err)
}
if !isAnon && !isTraining {
return []model.CampaignGroupStat{}, nil
}
// hide per group outcomes for a campaign anonymized after the fact too, so this
// matches the report path and stays safe if snapshotting ever changes
isAnonymized, err := c.CampaignRepository.IsAnonymizedByID(ctx, campaignID)
if err != nil {
c.Logger.Errorw("failed to check campaign anonymized flag for grouped stats", "error", err)
return nil, errs.Wrap(err)
}
stats, err := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, groupColumn)
if err != nil {
c.Logger.Errorw("failed to get grouped result stats", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return suppressSmallGroups(stats, isAnon || isAnonymized), nil
}
// HasGroupData reports whether the campaign's recipients carry any position or
// department, so the UI can decide whether to offer the grouped breakdown.
func (c *Campaign) HasGroupData(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
) (bool, error) {
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return false, errs.Wrap(err)
}
if !isAuthorized {
return false, errs.ErrAuthorizationFailed
}
return c.CampaignRepository.HasGroupDataByCampaignID(ctx, campaignID)
}
// hasNamedGroups reports whether any group has a real attribute value rather than
// the "(unspecified)" or Other placeholder.
func hasNamedGroups(stats []model.CampaignGroupStat) bool {
for _, g := range stats {
if g.Group != "(unspecified)" && g.Group != OTHER_GROUP_LABEL {
return true
}
}
return false
}
// suppressSmallGroups merges groups below the floor into an "Other" bucket, then
// absorbs the smallest shown groups until that bucket also reaches the floor, so a
// small group cannot be recovered by subtracting the shown groups from the total.
// When hideOutcomes is set (anonymized campaigns) every per group outcome is withheld
// and only the group sizes are shown: publishing per group outcomes alongside the
// campaign wide totals lets a shown or dashed column be back solved to an
// individual's result, so anonymized campaigns expose group sizes only. Normal and
// training campaigns keep exact counts.
func suppressSmallGroups(stats []model.CampaignGroupStat, hideOutcomes bool) []model.CampaignGroupStat {
shown := []model.CampaignGroupStat{}
var other model.CampaignGroupStat
other.Group = OTHER_GROUP_LABEL
addToOther := func(g model.CampaignGroupStat) {
other.Total += g.Total
other.Clicked += g.Clicked
other.Submitted += g.Submitted
other.Reported += g.Reported
other.TrainingStarted += g.TrainingStarted
other.TrainingCompleted += g.TrainingCompleted
}
for _, g := range stats {
if g.Total >= anonymityGroupFloor {
shown = append(shown, g)
} else {
addToOther(g)
}
}
// smallest shown first, so absorbing to reach the floor sacrifices the least detail
sort.Slice(shown, func(i, j int) bool { return shown[i].Total < shown[j].Total })
for other.Total > 0 && other.Total < anonymityGroupFloor && len(shown) > 0 {
addToOther(shown[0])
shown = shown[1:]
}
if other.Total > 0 {
// only possible when the whole campaign has fewer than `floor` people, where
// the campaign-wide aggregate is unavoidable anyway; withhold the breakdown.
if other.Total < anonymityGroupFloor {
other.Clicked = 0
other.Submitted = 0
other.Reported = 0
other.TrainingStarted = 0
other.TrainingCompleted = 0
other.Suppressed = true
}
shown = append(shown, other)
}
if hideOutcomes {
// anonymized campaigns show group sizes only; -1 renders as a dash
for i := range shown {
if !shown[i].Suppressed {
shown[i].Clicked = -1
shown[i].Submitted = -1
shown[i].Reported = -1
shown[i].TrainingStarted = -1
shown[i].TrainingCompleted = -1
}
}
}
return shown
}
// GetRecipientsByCampaignID gets all recipients for a campaign
func (c *Campaign) GetRecipientsByCampaignID(
ctx context.Context,
@@ -1288,8 +1499,22 @@ func (c *Campaign) GetRecipientsByCampaignID(
c.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get all recipients
if options.OrderBy == "" {
// an anonymous campaign keeps identity but withholds each outcome and timing
// (below). force an identity neutral order first so those withheld values cannot
// be read back from the row order. fail closed: on error, treat as anonymous.
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
c.Logger.Errorw("failed to check campaign anonymity before returning recipients", "error", err)
isAnon = true
}
if isAnon {
options.OrderBy = "recipients.first_name"
// search matches every allowed column, including the outcome and timing
// columns withheld from the response, so it could filter the named list by
// who clicked or when. clear it so only the sort is available, on a neutral
// column.
options.Search = ""
} else if options.OrderBy == "" {
options.OrderBy = "campaign_recipients.sent_at"
}
result, err = c.CampaignRecipientRepository.GetByCampaignID(
@@ -1301,6 +1526,18 @@ func (c *Campaign) GetRecipientsByCampaignID(
c.Logger.Errorw("failed to get recipients by campaign id", "error", err)
return result, errs.Wrap(err)
}
if isAnon {
for _, row := range result.Rows {
row.NotableEventID.SetNull()
row.NotableEventName = ""
// replace exact timing with a coarse sent indicator
row.Sent = row.SentAt.IsSpecified() && !row.SentAt.IsNull()
row.SendAt.SetNull()
row.SentAt.SetNull()
row.LastAttemptAt.SetNull()
row.CancelledAt.SetNull()
}
}
// no audit on read
return result, nil
}
@@ -1582,6 +1819,14 @@ func (c *Campaign) SaveTrackingPixelLoaded(
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return errs.Wrap(err)
}
// an anonymized campaign recipient has no recipient id, which happens for a
// closed anonymous campaign whose link was severed. these public endpoints can
// be hit long after close from a token in a delivered email, so return quietly
// rather than dereferencing a null id.
if campaignRecipient.RecipientID.IsNull() {
c.Logger.Debugw("skipping tracking pixel event: recipient is anonymized", "campaignRecipientID", campaignRecipientID.String())
return nil
}
recipientID := campaignRecipient.RecipientID.MustGet()
campaignID := campaignRecipient.CampaignID.MustGet()
@@ -1625,6 +1870,7 @@ func (c *Campaign) SaveTrackingPixelLoaded(
Data: vo.NewEmptyOptionalString1MB(),
Metadata: metadata,
}
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
c.Logger.Errorw("failed to save tracking pixel loaded event", "error", err)
@@ -1677,6 +1923,14 @@ func (c *Campaign) SaveRecipientReported(
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return errs.Wrap(err)
}
// an anonymized campaign recipient has no recipient id, which happens for a
// closed anonymous campaign whose link was severed. this public endpoint can be
// hit long after close from a token in a delivered email, so return quietly
// rather than dereferencing a null id.
if campaignRecipient.RecipientID.IsNull() {
c.Logger.Debugw("skipping report event: recipient is anonymized", "campaignRecipientID", campaignRecipientID.String())
return nil
}
recipientID := campaignRecipient.RecipientID.MustGet()
campaignID := campaignRecipient.CampaignID.MustGet()
@@ -1704,8 +1958,15 @@ func (c *Campaign) SaveRecipientReported(
reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
// de-duplicate: a recipient that already reported is not recorded again so a
// button that fires more than once does not spam the timeline
alreadyReported, err := c.CampaignRepository.HasEvent(ctx.Request.Context(), &campaignID, &recipientID, reportedEventID)
// button that fires more than once does not spam the timeline. an anonymous
// campaign's events carry the pseudonym rather than a recipient id, so dedup on
// that instead.
var alreadyReported bool
if aid, aErr := campaignRecipient.AnonymizedID.Get(); aErr == nil {
alreadyReported, err = c.CampaignRepository.HasEventByAnonymizedID(ctx.Request.Context(), &campaignID, &aid, reportedEventID)
} else {
alreadyReported, err = c.CampaignRepository.HasEvent(ctx.Request.Context(), &campaignID, &recipientID, reportedEventID)
}
if err != nil {
c.Logger.Errorw("failed to check existing reported event", "error", err)
return errs.Wrap(err)
@@ -1733,6 +1994,7 @@ func (c *Campaign) SaveRecipientReported(
Data: vo.NewEmptyOptionalString1MB(),
Metadata: metadata,
}
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
c.Logger.Errorw("failed to save reported event", "error", err)
@@ -1863,6 +2125,24 @@ func (c *Campaign) UpdateByID(
current.SaveBrowserMetadata.Set(v)
}
if v, err := incoming.IsAnonymous.Get(); err == nil {
// anonymity cannot change once recipients are materialized: pseudonyms are
// assigned at materialization, and a self managed campaign is not otherwise
// edit locked, so a late flip to anonymous would leave already recorded events
// carrying identity. block the change once any recipient exists.
currentAnon := campaignAnonymous(current)
if v != currentAnon {
hasRecipients, hErr := c.CampaignRecipientRepository.HasRecipientsByCampaignID(ctx, id)
if hErr != nil {
c.Logger.Errorw("failed to check recipients before changing anonymity", "error", hErr)
return errs.Wrap(hErr)
}
if hasRecipients {
return validate.WrapErrorWithField(
errors.New("anonymous mode cannot be changed after recipients are added"),
"isAnonymous",
)
}
}
current.IsAnonymous.Set(v)
}
if v, err := incoming.IsTest.Get(); err == nil {
@@ -2590,8 +2870,8 @@ func (c *Campaign) sendCampaignMessages(
)
return errs.Wrap(errors.Join(err, closeErr))
}
// validate the template is parseable before entering the per-recipient loop.
// the actual per-recipient execution uses TemplateFuncsWithDeviceCode so that
// validate the template is parseable before entering the per recipient loop.
// the actual per recipient execution uses TemplateFuncsWithDeviceCode so that
// {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly.
t := template.New("email")
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
@@ -2886,7 +3166,7 @@ func (c *Campaign) sendCampaignMessages(
// the only builder that knows about proxy first pages and path mode codes
(*t)["URL"] = customCampaignURL
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves
// build per recipient template funcs so that {{MicrosoftDeviceCode}} resolves
// to a real device code for this campaign recipient.
// use the actual recipient id (not the campaign recipient row id) so the
// lookup key matches what the landing page path uses.
@@ -2914,7 +3194,7 @@ func (c *Campaign) sendCampaignMessages(
// to the correct recipient id
recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(content.String())
if err != nil {
c.Logger.Errorw("failed to parse per-recipient mail template", "error", err)
c.Logger.Errorw("failed to parse per recipient mail template", "error", err)
return errs.Wrap(err)
}
var bodyBuffer bytes.Buffer
@@ -3207,6 +3487,18 @@ func (c *Campaign) saveSendingResult(
Data: data,
Metadata: vo.NewEmptyOptionalString1MB(),
}
// a send failure reason can contain the recipient address, so an anonymous
// campaign must not keep it. Anonymize blanks the data along with identity. a real
// anonymous recipient always carries a pseudonym here (assigned at materialization),
// and the helper strips on that regardless of the flag; the flag only covers the
// should-not-happen no-pseudonym case, so on a lookup error default to not
// anonymous to avoid stripping a normal campaign's send event.
sendIsAnon, anonErr := c.CampaignRepository.IsAnonymousByID(ctx, &campaignID)
if anonErr != nil {
c.Logger.Errorw("failed to check campaign anonymity for send result event", "error", anonErr)
sendIsAnon = false
}
anonymizeEventForRecipient(sendIsAnon, campaignRecipient, campaignEvent)
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
return fmt.Errorf("failed to save event: %s", err)
@@ -3694,6 +3986,19 @@ func (c *Campaign) closeCampaign(
}
}
// an anonymous campaign severs its recipient relation at close so no identity is
// retained after it ends. best effort here; the anonymize sweep (GetReadyToAnonymize)
// is the fallback that retries if this fails. skip if already anonymized, e.g. when
// AnonymizeByID drove this close.
if campaignAnonymous(campaign) {
alreadyAnonymized := campaign.AnonymizedAt.IsSpecified() && !campaign.AnonymizedAt.IsNull()
if !alreadyAnonymized {
if err := c.anonymizeCampaignRecipients(ctx, id); err != nil {
c.Logger.Errorw("failed to anonymize recipients at close, sweep will retry", "error", err, "campaignID", id.String())
}
}
}
// automatic report delivery when the company has opted in (skip test campaigns).
// rendering the PDF and sending mail can take seconds, so it runs in the
// background with its own context to avoid blocking the close path and the
@@ -4259,6 +4564,7 @@ func (c *Campaign) SetSentAtByCampaignRecipientID(
Data: details,
Metadata: vo.NewEmptyOptionalString1MB(),
}
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
@@ -4330,9 +4636,26 @@ func (c *Campaign) HandleWebhooks(
return errs.Wrap(err)
}
// an anonymous campaign must never send identity or captured data off box, so
// the effective webhook level is capped and email and data are withheld
// regardless of each webhook's configured level.
isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
return errs.Wrap(err)
}
// an anonymous campaign must not push per recipient events to a webhook: even
// without identity, one delivery per recipient action at its exact instant
// reinstates the timing the recipient view deliberately coarsens, which in a
// small campaign can single out who acted. campaign-level events (recipientID
// nil, e.g. campaign closed) still fire.
if isAnon && recipientID != nil {
return nil
}
// get email once for all webhooks
var email *vo.Email
if recipientID != nil {
if recipientID != nil && !isAnon {
email, err = c.RecipientRepository.GetEmailByID(ctx, recipientID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errs.Wrap(err)
@@ -4361,6 +4684,11 @@ func (c *Campaign) HandleWebhooks(
webhookID := webhookConfig.WebhookID.MustGet()
webhookEvents := webhookConfig.GetWebhookEventsOrDefault()
dataLevel := webhookConfig.GetWebhookIncludeDataOrDefault()
// cap the level for an anonymous campaign: basic still names the campaign
// but carries no email and no captured data; full would leak both.
if isAnon && dataLevel == model.WebhookDataLevelFull {
dataLevel = model.WebhookDataLevelBasic
}
// check if this event should trigger this webhook
if !model.IsWebhookEventEnabled(webhookEvents, eventName) {
@@ -4423,6 +4751,86 @@ func (c *Campaign) HandleWebhook(
return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData)
}
// anonymizeCampaignRecipients severs the recipient relation for a campaign and
// finalizes anonymization. Each recipient keeps its existing pseudonym (an
// anonymous campaign assigned one at materialization) or is given a fresh one and
// has its events rewritten (a normal campaign anonymized at close). Lure codes are
// released, recipient group links dropped, recipient ids removed, and anonymized_at
// stamped. The campaign must already be closed. Safe to run more than once: a
// second run finds recipient ids already null and skips them.
func (c *Campaign) anonymizeCampaignRecipients(ctx context.Context, id *uuid.UUID) error {
campaignRecipientsResult, err := c.CampaignRecipientRepository.GetByCampaignID(
ctx,
id,
&repository.CampaignRecipientOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipients by campaign id", "error", err)
return errs.Wrap(err)
}
for _, cr := range campaignRecipientsResult.Rows {
if cr.RecipientID.IsNull() {
c.Logger.Debug("skipping anonymization of campaign recipient without recipient")
continue
}
recipientID := cr.RecipientID.MustGet()
campaignID, err := cr.CampaignID.Get()
if err != nil {
c.Logger.Debug("Recipient removed or anonymized, skipping in anonymization")
continue
}
// an anonymous campaign already assigned a stable pseudonym at
// materialization and its events already carry it. keep that id, only
// release the lure code and sever the recipient link below. rewriting
// events would fail to match, since they hold no recipient id, and
// regenerating the id would orphan the events already written.
if existing, err := cr.AnonymizedID.Get(); err == nil {
if err := c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &existing); err != nil {
c.Logger.Errorw("failed to finalize anonymous campaign recipient", "error", err)
return errs.Wrap(err)
}
continue
}
// add anonymized ID to each campaign recipient
anonymizedID := uuid.New()
cr.AnonymizedID = nullable.NewNullableWithValue(anonymizedID)
err = c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &anonymizedID)
if err != nil {
c.Logger.Errorw("failed to add anonymized ID to campaign recipient", "error", err)
return errs.Wrap(err)
}
// anonymize events and assign each anonymized ID so the events can still be tracked
err = c.CampaignRepository.AnonymizeCampaignEvent(
ctx,
&campaignID,
&recipientID,
&anonymizedID,
)
if err != nil {
c.Logger.Errorw("failed to anonymize campaign event", "error", err)
return errs.Wrap(err)
}
}
// delete the relation between the campaign and the recipient groups
err = c.CampaignRepository.RemoveCampaignRecipientGroups(ctx, id)
if err != nil {
c.Logger.Errorw("failed to delete campaign recipient groups by campaign id", "error", err)
return errs.Wrap(err)
}
// remove the recipient ID from the campaign recipient so only the anomymized ID is left
err = c.CampaignRecipientRepository.RemoveRecipientIDByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to remove recipient ID from campaign recipients", "error", err)
return errs.Wrap(err)
}
// finally add a timestamp to the campaign to indicate when it was anonymized
if err := c.CampaignRepository.AddAnonymizedAt(ctx, id); err != nil {
c.Logger.Errorw("failed to add anonymized at to campaign", "error", err)
return errs.Wrap(err)
}
return nil
}
// AnonymizeByID anonymizes a campaign including the events
func (c *Campaign) AnonymizeByID(
ctx context.Context,
@@ -4464,63 +4872,9 @@ func (c *Campaign) AnonymizeByID(
c.Logger.Errorw("failed to close campaign by id before anonymization", "error", err)
return errs.Wrap(err)
}
// assign a anonymized ID to each campaign recipient and make a map between
// their ID and the anonymized ID, this is a itermidiate step to anonymize the events
// where campaign receipients have both a anonymized ID and the recipient ID
campaignRecipientsResult, err := c.CampaignRecipientRepository.GetByCampaignID(
ctx,
id,
&repository.CampaignRecipientOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipients by campaign id", "error", err)
if err := c.anonymizeCampaignRecipients(ctx, id); err != nil {
return errs.Wrap(err)
}
for _, cr := range campaignRecipientsResult.Rows {
if cr.RecipientID.IsNull() {
c.Logger.Debug("skipping anonymization of campaign recipient without recipient")
continue
}
// add anonymized ID to each campaign recipient
anonymizedID := uuid.New()
cr.AnonymizedID = nullable.NewNullableWithValue(anonymizedID)
recipientID := cr.RecipientID.MustGet()
campaignID, err := cr.CampaignID.Get()
if err != nil {
c.Logger.Debug("Recipient removed or anonymized, skipping in anonymization")
continue
}
err = c.CampaignRecipientRepository.Anonymize(ctx, &campaignID, &recipientID, &anonymizedID)
if err != nil {
c.Logger.Errorw("failed to add anonymized ID to campaign recipient", "error", err)
return errs.Wrap(err)
}
// anonymize events and assign each anonymized ID so the events can still be tracked
err = c.CampaignRepository.AnonymizeCampaignEvent(
ctx,
&campaignID,
&recipientID,
&anonymizedID,
)
if err != nil {
c.Logger.Errorw("failed to anonymize campaign event", "error", err)
return errs.Wrap(err)
}
}
// delete the relation between the campaign and the recipient groups
err = c.CampaignRepository.RemoveCampaignRecipientGroups(ctx, id)
if err != nil {
c.Logger.Errorw("failed to delete campaign recipient groups by campaign id", "error", err)
return errs.Wrap(err)
}
// remove the recipient ID from the campaign recipient so only the anomymized ID is left
err = c.CampaignRecipientRepository.RemoveRecipientIDByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to remove recipient ID from campaign recipients", "error", err)
return errs.Wrap(err)
}
// finally add a timestamp to the campaign to indicate when it was anonymized
err = c.CampaignRepository.AddAnonymizedAt(ctx, id)
c.AuditLogAuthorized(ae)
return nil
@@ -4746,7 +5100,7 @@ func (c *Campaign) sendSingleCampaignMessage(
return errors.New("failed to get email content")
}
// validate parseability with company funcs; execution uses per-recipient device code funcs
// validate parseability with company funcs; execution uses per recipient device code funcs
t := template.New("email")
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
_, err = t.Parse(content.String())
@@ -4908,7 +5262,7 @@ func (c *Campaign) sendSingleEmailSMTP(
return errs.Wrap(err)
}
// build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly.
// build per recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly.
// use the actual recipient id (not the campaign recipient row id) so the
// lookup key matches what the landing page path uses.
recipientID := campaignRecipient.ID.MustGet()
@@ -4967,10 +5321,10 @@ func (c *Campaign) sendSingleEmailSMTP(
}
m.Subject(subjectBuffer.String())
// parse and execute body with per-recipient device code funcs
// parse and execute body with per recipient device code funcs
recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(emailContent)
if err != nil {
c.Logger.Errorw("failed to parse per-recipient mail template", "error", err)
c.Logger.Errorw("failed to parse per recipient mail template", "error", err)
return errs.Wrap(err)
}
var bodyBuffer bytes.Buffer
@@ -5720,25 +6074,34 @@ func (c *Campaign) ProcessReportedCSV(
continue
}
// check if already reported (to avoid duplicates)
existingEvent, err := c.CampaignRepository.GetEventsByCampaignID(
ctx,
campaignID,
&repository.CampaignEventOption{
QueryArgs: &vo.QueryArgs{
Limit: 1,
},
EventTypeIDs: []string{reportedEventID.String()},
},
nil,
)
// check if already reported (to avoid duplicates). an anonymous campaign's
// events carry the pseudonym rather than a recipient id, so dedup on that.
alreadyReported := false
if err == nil && existingEvent != nil {
for _, event := range existingEvent.Rows {
if event.RecipientID != nil && *event.RecipientID == recipientID {
alreadyReported = true
break
if aid, aErr := campaignRecipient.AnonymizedID.Get(); aErr == nil {
alreadyReported, err = c.CampaignRepository.HasEventByAnonymizedID(ctx, campaignID, &aid, reportedEventID)
if err != nil {
c.Logger.Errorw("failed to check existing reported event", "error", err)
skipped++
continue
}
} else {
existingEvent, gErr := c.CampaignRepository.GetEventsByCampaignID(
ctx,
campaignID,
&repository.CampaignEventOption{
QueryArgs: &vo.QueryArgs{
Limit: 1,
},
EventTypeIDs: []string{reportedEventID.String()},
},
nil,
)
if gErr == nil && existingEvent != nil {
for _, event := range existingEvent.Rows {
if event.RecipientID != nil && *event.RecipientID == recipientID {
alreadyReported = true
break
}
}
}
}
@@ -5762,6 +6125,7 @@ func (c *Campaign) ProcessReportedCSV(
Data: vo.NewEmptyOptionalString1MB(),
Metadata: vo.NewEmptyOptionalString1MB(),
}
anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent)
// save the event with custom timestamp
err = c.saveReportedEvent(campaignEvent, parsedDate)
@@ -5809,6 +6173,9 @@ func (c *Campaign) saveReportedEvent(
if campaignEvent.RecipientID != nil {
row["recipient_id"] = campaignEvent.RecipientID.String()
}
if campaignEvent.AnonymizedID != nil {
row["anonymized_id"] = campaignEvent.AnonymizedID.String()
}
res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row)
if res.Error != nil {
@@ -5891,10 +6258,13 @@ func (c *Campaign) buildReportHTMLWithData(
}
}
// the per recipient table is only available while the recipient relation exists
// the per recipient table exposes identity, so it is omitted for an anonymous
// campaign (even while live, before the anonymization sweep sets AnonymizedAt)
// and for any campaign that has already been anonymized.
var recipients []model.ReportRecipient
isAnon := campaignAnonymous(campaign)
isAnonymized := campaign.AnonymizedAt.IsSpecified() && !campaign.AnonymizedAt.IsNull()
if !isAnonymized {
if !isAnon && !isAnonymized {
recipients, err = c.CampaignRepository.GetReportRecipients(ctx, campaignID)
if err != nil {
c.Logger.Warnw("failed to get report recipients, continuing without detail table", "error", err)
@@ -5902,7 +6272,32 @@ func (c *Campaign) buildReportHTMLWithData(
}
}
rd := buildReportData(name, companyName, campaign, stats, recipients)
// breakdown for every campaign. default to department when present, else
// position; both dimensions are exposed so a template can render either.
var groups, departmentGroups, positionGroups []model.CampaignGroupStat
groupsBy := "Department"
if raw, gErr := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, "department"); gErr != nil {
c.Logger.Warnw("failed to get department grouped stats for report", "error", gErr)
} else {
departmentGroups = suppressSmallGroups(raw, isAnon || isAnonymized)
}
if raw, gErr := c.CampaignRepository.GetGroupedResultStats(ctx, campaignID, "position"); gErr != nil {
c.Logger.Warnw("failed to get position grouped stats for report", "error", gErr)
} else {
positionGroups = suppressSmallGroups(raw, isAnon || isAnonymized)
}
if hasNamedGroups(departmentGroups) {
groups, groupsBy = departmentGroups, "Department"
} else if hasNamedGroups(positionGroups) {
groups, groupsBy = positionGroups, "Position"
} else {
// the recipients carry no department or position, so there is nothing to
// group by. leave the default breakdown empty so the report omits the group
// page rather than showing a single unlabeled bucket.
groups = nil
}
rd := buildReportData(name, companyName, campaign, stats, recipients, groups, groupsBy, departmentGroups, positionGroups)
var buf bytes.Buffer
tmpl, err := template.New("report").Funcs(TemplateFuncs()).Parse(templateContent)
@@ -6274,6 +6669,10 @@ func buildReportData(
campaign *model.Campaign,
stats *model.CampaignResultView,
recipients []model.ReportRecipient,
groups []model.CampaignGroupStat,
groupsBy string,
departmentGroups []model.CampaignGroupStat,
positionGroups []model.CampaignGroupStat,
) *model.ReportData {
isTrainingReport := false
if v, err := campaign.IsTraining.Get(); err == nil {
@@ -6346,10 +6745,44 @@ func buildReportData(
StartedOfOpened: relPct(stats.TrainingStarted, stats.TrackingPixelLoaded),
CompletedOfStarted: relPct(stats.TrainingCompleted, stats.TrainingStarted),
Recipients: recipients,
Recipients: recipients,
GroupsBy: groupsBy,
Groups: toReportGroupStats(groups),
DepartmentGroups: toReportGroupStats(departmentGroups),
PositionGroups: toReportGroupStats(positionGroups),
}
}
// toReportGroupStats converts raw grouped counts to report rows with the
// percentage of each group that clicked, submitted and reported pre-formatted.
func toReportGroupStats(groups []model.CampaignGroupStat) []model.ReportGroupStat {
groupPct := func(count, total int) string {
if count < 0 || total == 0 {
return "" // withheld (homogeneous) or n/a
}
return fmt.Sprintf("%.0f", float64(count)/float64(total)*100)
}
out := make([]model.ReportGroupStat, 0, len(groups))
for _, g := range groups {
out = append(out, model.ReportGroupStat{
Group: g.Group,
Total: g.Total,
Clicked: g.Clicked,
ClickedPercent: groupPct(g.Clicked, g.Total),
Submitted: g.Submitted,
SubmittedPercent: groupPct(g.Submitted, g.Total),
Reported: g.Reported,
ReportedPercent: groupPct(g.Reported, g.Total),
TrainingStarted: g.TrainingStarted,
TrainingStartedPercent: groupPct(g.TrainingStarted, g.Total),
TrainingCompleted: g.TrainingCompleted,
TrainingCompletedPercent: groupPct(g.TrainingCompleted, g.Total),
Suppressed: g.Suppressed,
})
}
return out
}
// htmlEscapeReportData returns a copy of the report data with the externally
// supplied strings HTML escaped, for the HTML contexts (the rendered report and
// the html email body). text/template does not escape values, so this stops
@@ -6372,5 +6805,17 @@ func htmlEscapeReportData(rd *model.ReportData) *model.ReportData {
r.Position = template.HTMLEscapeString(r.Position)
cp.Recipients[i] = r
}
cp.GroupsBy = template.HTMLEscapeString(rd.GroupsBy)
escapeGroups := func(in []model.ReportGroupStat) []model.ReportGroupStat {
out := make([]model.ReportGroupStat, len(in))
for i, g := range in {
g.Group = template.HTMLEscapeString(g.Group)
out[i] = g
}
return out
}
cp.Groups = escapeGroups(rd.Groups)
cp.DepartmentGroups = escapeGroups(rd.DepartmentGroups)
cp.PositionGroups = escapeGroups(rd.PositionGroups)
return &cp
}
+56 -13
View File
@@ -409,6 +409,39 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
return entry, nil
}
// applyEventAnonymization strips identity and stamps the pseudonym on a device
// code event when the campaign is anonymous. It fails closed: if the recipient
// cannot be loaded and the campaign is anonymous, or anonymity cannot be
// determined, the event is stored without identity rather than with it.
func (s *MicrosoftDeviceCode) applyEventAnonymization(
ctx context.Context,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
event *model.CampaignEvent,
) {
if campaignID == nil || recipientID == nil {
return
}
// decide from the authoritative campaign flag, not the presence of a pseudonym.
// fail closed: if anonymity cannot be determined, strip identity.
isAnon, err := s.CampaignRepository.IsAnonymousByID(ctx, campaignID)
if err != nil {
s.Logger.Errorw("could not confirm campaign anonymity for device code event, storing without identity", "error", err)
event.Anonymize(nil)
return
}
if !isAnon {
return
}
if cr, crErr := s.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil {
if aid, aerr := cr.AnonymizedID.Get(); aerr == nil {
event.Anonymize(&aid)
return
}
}
event.Anonymize(nil)
}
// saveDeviceCodeCreatedEvent saves a campaign event recording that a device code was created (or
// failed to be created). failReason should be empty on success.
func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
@@ -450,6 +483,7 @@ func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
Data: eventData,
Metadata: vo.NewEmptyOptionalString1MB(),
}
s.applyEventAnonymization(ctx, campaignID, recipientID, campaignEvent)
if saveErr := s.CampaignRepository.SaveEvent(ctx, campaignEvent); saveErr != nil {
s.Logger.Errorw("failed to save device code created event", "error", saveErr)
}
@@ -523,18 +557,6 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
return nil
}
// we have tokens — mark the entry as captured
if err := s.MicrosoftDeviceCodeRepository.MarkCaptured(
ctx,
&entryID,
tokenResp.AccessToken,
tokenResp.RefreshToken,
tokenResp.IDToken,
); err != nil {
s.Logger.Errorw("failed to mark device code as captured", "error", err)
return errs.Wrap(err)
}
campaignID, err := entry.CampaignID.Get()
if err != nil {
s.Logger.Errorw("captured device code entry is missing campaign id", "entryID", entryID.String())
@@ -547,12 +569,32 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
return fmt.Errorf("device code entry %s has no recipient id", entryID.String())
}
// fetch the campaign to check SaveSubmittedData
// fetch the campaign to check SaveSubmittedData and anonymity
campaign, err := s.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{})
if err != nil {
s.Logger.Errorw("failed to get campaign for submit event", "error", err)
return errs.Wrap(err)
}
// fail closed: treat as anonymous so captured tokens are never persisted
isAnon := campaignAnonymous(campaign)
// we have tokens — mark the entry as captured. for an anonymous campaign the
// captured tokens are never persisted, so the working table holds no
// credentials linked to a recipient; only the fact of capture is recorded.
accessTok, refreshTok, idTok := tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.IDToken
if isAnon {
accessTok, refreshTok, idTok = "", "", ""
}
if err := s.MicrosoftDeviceCodeRepository.MarkCaptured(
ctx,
&entryID,
accessTok,
refreshTok,
idTok,
); err != nil {
s.Logger.Errorw("failed to mark device code as captured", "error", err)
return errs.Wrap(err)
}
// build event data json containing the captured tokens
eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID)
@@ -582,6 +624,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
Data: submitData,
Metadata: vo.NewEmptyOptionalString1MB(),
}
s.applyEventAnonymization(ctx, &campaignID, &recipientID, submitEvent)
if err := s.CampaignRepository.SaveEvent(ctx, submitEvent); err != nil {
s.Logger.Errorw("failed to save device code submit event", "error", err)
return errs.Wrap(err)
+21
View File
@@ -861,6 +861,27 @@ export class API {
return await getJSON(this.getPath(`/campaign/${campaignID}/statistics`));
},
/**
* Get campaign outcome stats grouped by a recipient attribute.
* @param {string} campaignID
* @param {'position'|'department'} by
* @returns {Promise<ApiResponse>}
*/
getGroupedResultStats: async (campaignID, by = 'position') => {
return await getJSON(
this.getPath(`/campaign/${campaignID}/grouped-statistics?by=${by}`)
);
},
/**
* Whether the campaign has any position/department data to group on.
* @param {string} campaignID
* @returns {Promise<ApiResponse>}
*/
getHasGroupData: async (campaignID) => {
return await getJSON(this.getPath(`/campaign/${campaignID}/has-group-data`));
},
/**
* Get campaign recipient email.
*
@@ -8,6 +8,7 @@
export let optional = false;
export let toolTipText = '';
export let width = 'medium';
export let disabled = false;
/** @type {*} */
export let onChange = () => {};
</script>
@@ -37,16 +38,19 @@
{#each options as option}
<button
type="button"
{disabled}
class:h32={option.icon && option.description}
class:h16={!option.icon && !option.description}
class:w-28={width === 'small'}
class:w-40={width === 'medium'}
class:w-64={width === 'large'}
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
class={`
p-3 rounded-lg border-2 transition-all duration-200
flex flex-col items-center justify-center text-center
w-40
hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20
${disabled ? '' : 'hover:border-blue-300 dark:hover:border-highlight-blue/80 hover:bg-blue-50 dark:hover:bg-highlight-blue/20'}
${
value === option.value
? 'border-green-500 dark:border-green-400 bg-green-50 dark:bg-green-800/40 text-green-700 dark:text-green-300'
@@ -54,6 +58,7 @@
}
`}
on:click={() => {
if (disabled) return;
value = option.value;
onChange();
}}
+313 -39
View File
@@ -104,26 +104,314 @@
return result;
})();
const reportTemplates = [
{ label: 'Campaign Name', text: '{{.CampaignName}}' },
{ label: 'Company Name', text: '{{.CompanyName}}' },
{ label: 'Report Date', text: '{{.ReportDate}}' },
{ label: 'Start Date', text: '{{.CampaignStartDate}}' },
{ label: 'End Date', text: '{{.CampaignEndDate}}' },
{ label: 'Closed At', text: '{{.CampaignClosedAt}}' },
{ label: 'Total Targets', text: '{{.TotalTargets}}' },
{ label: 'Emails Sent', text: '{{.EmailsSent}}' },
{ label: 'Emails Opened', text: '{{.EmailsOpened}}' },
{ label: 'Clicked (count)', text: '{{.ResultClicked}}' },
{ label: 'Clicked (% of total)', text: '{{.ResultClickedPercent}}' },
{ label: 'Submitted (count)', text: '{{.ResultSubmitted}}' },
{ label: 'Submitted (% of total)', text: '{{.ResultSubmittedPercent}}' },
{ label: 'Reported (count)', text: '{{.ResultReported}}' },
{ label: 'Reported (% of total)', text: '{{.ResultReportedPercent}}' },
{ label: 'Opened of sent (%)', text: '{{.OpenedOfSent}}' },
{ label: 'Clicked of opened (%)', text: '{{.ClickedOfOpened}}' },
{ label: 'Submitted of clicked (%)', text: '{{.SubmittedOfClicked}}' }
const reportCategories = {
'Campaign Info': [
{ label: 'Campaign Name', text: '{{.CampaignName}}' },
{ label: 'Company Name', text: '{{.CompanyName}}' },
{ label: 'Report Date', text: '{{.ReportDate}}' },
{ label: 'Start Date', text: '{{.CampaignStartDate}}' },
{ label: 'End Date', text: '{{.CampaignEndDate}}' },
{ label: 'Closed At', text: '{{.CampaignClosedAt}}' },
{ label: 'Total Targets', text: '{{.TotalTargets}}' }
],
Results: [
{ label: 'Emails Sent', text: '{{.EmailsSent}}' },
{ label: 'Emails Opened', text: '{{.EmailsOpened}}' },
{ label: 'Clicked (count)', text: '{{.ResultClicked}}' },
{ label: 'Clicked (% of total)', text: '{{.ResultClickedPercent}}' },
{ label: 'Submitted (count)', text: '{{.ResultSubmitted}}' },
{ label: 'Submitted (% of total)', text: '{{.ResultSubmittedPercent}}' },
{ label: 'Reported (count)', text: '{{.ResultReported}}' },
{ label: 'Reported (% of total)', text: '{{.ResultReportedPercent}}' }
],
'Conversion & Rates': [
{ label: 'Opened of sent (%)', text: '{{.OpenedOfSent}}' },
{ label: 'Clicked of opened (%)', text: '{{.ClickedOfOpened}}' },
{ label: 'Submitted of clicked (%)', text: '{{.SubmittedOfClicked}}' },
{ label: 'Sent rate (0-100)', text: '{{.SentRate}}' },
{ label: 'Open rate (0-100)', text: '{{.OpenRate}}' },
{ label: 'Click rate (0-100)', text: '{{.ClickRate}}' },
{ label: 'Submit rate (0-100)', text: '{{.SubmitRate}}' },
{ label: 'Report rate (0-100)', text: '{{.ReportRate}}' }
],
'Awareness Training': [
{ label: 'Is training campaign', text: '{{.IsTraining}}' },
{ label: 'Training Started (count)', text: '{{.TrainingStarted}}' },
{ label: 'Training Started (% of total)', text: '{{.TrainingStartedPercent}}' },
{ label: 'Training Started rate (0-100)', text: '{{.TrainingStartedRate}}' },
{ label: 'Training Completed (count)', text: '{{.TrainingCompleted}}' },
{ label: 'Training Completed (% of total)', text: '{{.TrainingCompletedPercent}}' },
{ label: 'Training Completed rate (0-100)', text: '{{.TrainingCompletedRate}}' },
{ label: 'Started of opened (%)', text: '{{.StartedOfOpened}}' },
{ label: 'Completed of started (%)', text: '{{.CompletedOfStarted}}' }
],
'Group Breakdown': [
{ label: 'Grouped By (default dimension name)', text: '{{.GroupsBy}}' },
{
label: 'Group rows — default dimension (loop)',
text: `{{range .Groups}}
<tr>
<td>{{.Group}}</td>
<td>{{.Total}}</td>
{{if .Suppressed}}
<td colspan="3">Hidden (group too small)</td>
{{else}}
<td>{{if lt .Clicked 0}}{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
<td>{{if lt .Submitted 0}}{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}</td>
<td>{{if lt .Reported 0}}{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}</td>
{{end}}
</tr>
{{end}}`
},
{
label: 'Group rows — by department (loop)',
text: `{{range .DepartmentGroups}}
<tr>
<td>{{.Group}}</td>
<td>{{.Total}}</td>
{{if .Suppressed}}
<td colspan="3">Hidden (group too small)</td>
{{else}}
<td>{{if lt .Clicked 0}}{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
<td>{{if lt .Submitted 0}}{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}</td>
<td>{{if lt .Reported 0}}{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}</td>
{{end}}
</tr>
{{end}}`
},
{
label: 'Group rows — by position (loop)',
text: `{{range .PositionGroups}}
<tr>
<td>{{.Group}}</td>
<td>{{.Total}}</td>
{{if .Suppressed}}
<td colspan="3">Hidden (group too small)</td>
{{else}}
<td>{{if lt .Clicked 0}}{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
<td>{{if lt .Submitted 0}}{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}}</td>
<td>{{if lt .Reported 0}}{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}}</td>
{{end}}
</tr>
{{end}}`
},
{
label: 'Group rows — training (loop)',
text: `{{range .Groups}}
<tr>
<td>{{.Group}}</td>
<td>{{.Total}}</td>
{{if .Suppressed}}
<td colspan="3">Hidden (group too small)</td>
{{else}}
<td>{{if lt .Clicked 0}}{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}}</td>
<td>{{if lt .TrainingStarted 0}}{{else}}{{.TrainingStarted}} ({{.TrainingStartedPercent}}%){{end}}</td>
<td>{{if lt .TrainingCompleted 0}}{{else}}{{.TrainingCompleted}} ({{.TrainingCompletedPercent}}%){{end}}</td>
{{end}}
</tr>
{{end}}`
}
],
Recipients: [
{
label: 'Recipient rows (loop)',
text: `{{range .Recipients}}
<tr>
<td>{{.FirstName}} {{.LastName}}</td>
<td>{{.Email}}</td>
<td>{{.Department}}</td>
<td>{{.Position}}</td>
<td>{{if .ClickedLink}}Yes{{else}}No{{end}}</td>
<td>{{if .SubmittedData}}Yes{{else}}No{{end}}</td>
<td>{{if .Reported}}Yes{{else}}No{{end}}</td>
</tr>
{{end}}`
}
]
};
// sample data for the live preview only. the real report is rendered by Go's
// html/template on the server; this approximates the subset it uses (printf, if
// blocks, range loops) so the preview shows values, not raw tags.
const sampleDepartmentGroups = [
{ Group: 'Engineering', Total: 70, Clicked: 21, ClickedPercent: '30', Submitted: 9, SubmittedPercent: '13', Reported: 11, ReportedPercent: '16', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
{ Group: 'Sales', Total: 49, Clicked: 20, ClickedPercent: '41', Submitted: 10, SubmittedPercent: '20', Reported: 4, ReportedPercent: '8', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
{ Group: 'Other (small groups)', Total: 1, Clicked: 0, ClickedPercent: '0', Submitted: 0, SubmittedPercent: '0', Reported: 0, ReportedPercent: '0', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: true }
];
const samplePositionGroups = [
{ Group: 'Head of operations', Total: 40, Clicked: 12, ClickedPercent: '30', Submitted: 5, SubmittedPercent: '13', Reported: 6, ReportedPercent: '15', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
{ Group: 'Account Executive', Total: 35, Clicked: 14, ClickedPercent: '40', Submitted: 7, SubmittedPercent: '20', Reported: 3, ReportedPercent: '9', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: false },
{ Group: 'Other (small groups)', Total: 1, Clicked: 0, ClickedPercent: '0', Submitted: 0, SubmittedPercent: '0', Reported: 0, ReportedPercent: '0', TrainingStarted: 0, TrainingStartedPercent: '0', TrainingCompleted: 0, TrainingCompletedPercent: '0', Suppressed: true }
];
const reportSampleData = {
CampaignName: 'Q1 Phishing Simulation',
CompanyName: 'World Corp',
ReportDate: new Date().toLocaleDateString(),
CampaignStartDate: '2025-01-01',
CampaignEndDate: '2025-01-31',
CampaignClosedAt: '2025-02-01',
TotalTargets: 120,
EmailsSent: 118,
EmailsOpened: 74,
ResultClicked: 32,
ResultClickedPercent: '27.1',
ResultSubmitted: 14,
ResultSubmittedPercent: '11.9',
ResultReported: 8,
ResultReportedPercent: '6.8',
OpenedOfSent: '62.7',
ClickedOfOpened: '43.2',
SubmittedOfClicked: '43.8',
SentRate: 98.3,
OpenRate: 61.7,
ClickRate: 26.7,
SubmitRate: 11.7,
ReportRate: 6.7,
IsTraining: false,
TrainingStarted: 0,
TrainingCompleted: 0,
TrainingStartedPercent: '0',
TrainingCompletedPercent: '0',
TrainingStartedRate: 0,
TrainingCompletedRate: 0,
StartedOfOpened: '0',
CompletedOfStarted: '0',
GroupsBy: 'Department',
Groups: sampleDepartmentGroups,
DepartmentGroups: sampleDepartmentGroups,
PositionGroups: samplePositionGroups,
Recipients: [
{ FirstName: 'Alice', LastName: 'Andersen', Email: 'alice@worldcorp.test', Department: 'Research and Development', Position: 'Head of operations', ClickedLink: true, SubmittedData: true, Reported: false },
{ FirstName: 'Bob', LastName: 'Berg', Email: 'bob@worldcorp.test', Department: 'Sales', Position: 'Account Executive', ClickedLink: true, SubmittedData: false, Reported: false },
{ FirstName: 'Carol', LastName: 'Chan', Email: 'carol@worldcorp.test', Department: 'Finance', Position: 'Analyst', ClickedLink: false, SubmittedData: false, Reported: true }
]
};
// format a value the way Go's printf "%.Nf" would, for the preview only
function goFormatFloat(fmt, val) {
const m = /%\.(\d+)f/.exec(fmt);
if (m) return (Number(val) || 0).toFixed(Number(m[1]));
return String(val ?? '');
}
// test one template condition against the preview data: a bare field ".X"
// (truthiness) or a comparison "lt/gt/eq .X N" as used by the report group cells.
// pure string and number matching, no code execution
function matchReportCondition(expr, ctx) {
const cmp = /^(lt|gt|eq)\s+\.(\w+)\s+(-?[\d.]+)$/.exec(expr.trim());
if (cmp) {
const left = Number(ctx[cmp[2]]) || 0;
const right = Number(cmp[3]);
if (cmp[1] === 'lt') return left < right;
if (cmp[1] === 'gt') return left > right;
return left === right;
}
const bare = /^\.(\w+)$/.exec(expr.trim());
return bare ? !!ctx[bare[1]] : false;
}
// resolve {{if .X}} / {{else if .Y}} / {{else}} / {{end}} chains against a
// scope, honouring nested blocks so the matching {{end}} is found correctly
function resolveReportConditionals(text, ctx) {
for (;;) {
const start = text.indexOf('{{if ');
if (start === -1) break;
const head = /^\{\{if\s+([^}]+?)\}\}/.exec(text.slice(start));
if (!head) break;
const tokenRe = /\{\{if\b[^}]*\}\}|\{\{range\b[^}]*\}\}|\{\{else if\s+([^}]+?)\}\}|\{\{else\}\}|\{\{end\}\}/g;
tokenRe.lastIndex = start + head[0].length;
let depth = 1;
let endStop = -1;
const parts = [{ cond: head[1], from: start + head[0].length, to: -1 }];
let m;
while ((m = tokenRe.exec(text)) !== null) {
const tok = m[0];
if (tok.startsWith('{{if') || tok.startsWith('{{range')) {
depth += 1;
} else if (tok === '{{end}}') {
depth -= 1;
if (depth === 0) {
parts[parts.length - 1].to = m.index;
endStop = m.index + tok.length;
break;
}
} else if (depth === 1) {
parts[parts.length - 1].to = m.index;
parts.push({ cond: tok === '{{else}}' ? null : m[1], from: m.index + tok.length, to: -1 });
}
}
if (endStop === -1) break; // unbalanced, stop to avoid a loop
let chosen = '';
for (const p of parts) {
if (p.cond === null || matchReportCondition(p.cond, ctx)) {
chosen = text.slice(p.from, p.to);
break;
}
}
text = text.slice(0, start) + chosen + text.slice(endStop);
}
return text;
}
// resolve if blocks, printf, mul and bare fields against one scope (the root
// report data, or one row inside a range loop)
function renderReportScope(text, ctx) {
text = resolveReportConditionals(text, ctx);
text = text.replace(
/\{\{printf\s+"([^"]+)"\s+\(mul\s+\.(\w+)\s+([\d.]+)\)\}\}/g,
(_m, fmt, key, n) => goFormatFloat(fmt, (Number(ctx[key]) || 0) * Number(n))
);
text = text.replace(
/\{\{printf\s+"([^"]+)"\s+\.(\w+)\}\}/g,
(_m, fmt, key) => goFormatFloat(fmt, ctx[key])
);
text = text.replace(/\{\{\.(\w+)\}\}/g, (_m, key) =>
ctx[key] !== undefined && typeof ctx[key] !== 'object' ? String(ctx[key]) : ''
);
return text;
}
// expand a range loop by its balanced end, so a nested if inside the loop
// body does not cut the match short at the wrong {{end}}
function expandReportRange(text, name, items) {
const open = '{{range .' + name + '}}';
let idx;
while ((idx = text.indexOf(open)) !== -1) {
const innerStart = idx + open.length;
const tokenRe = /\{\{range\b[^}]*\}\}|\{\{if\b[^}]*\}\}|\{\{end\}\}/g;
tokenRe.lastIndex = innerStart;
let depth = 1;
let innerEnd = -1;
let endStop = -1;
let m;
while ((m = tokenRe.exec(text)) !== null) {
if (m[0] === '{{end}}') {
depth -= 1;
if (depth === 0) {
innerEnd = m.index;
endStop = m.index + m[0].length;
break;
}
} else {
depth += 1;
}
}
if (endStop === -1) break; // unbalanced, leave as is
const inner = text.slice(innerStart, innerEnd);
const rendered = items.map((it) => renderReportScope(inner, it)).join('');
text = text.slice(0, idx) + rendered + text.slice(endStop);
}
return text;
}
// expand the range loops, then render the top level scope
function renderReportPreview(text) {
text = expandReportRange(text, 'Recipients', reportSampleData.Recipients);
text = expandReportRange(text, 'DepartmentGroups', reportSampleData.DepartmentGroups);
text = expandReportRange(text, 'PositionGroups', reportSampleData.PositionGroups);
text = expandReportRange(text, 'Groups', reportSampleData.Groups);
return renderReportScope(text, reportSampleData);
}
switch (contentType) {
case 'domain': {
@@ -140,7 +428,11 @@
delete templates['Email'];
delete templates['Recipient'];
delete templates['URLs & Tracking'];
templates['Campaign'] = reportTemplates;
// report variables grouped into meaningful categories; keep Functions last
const reportFunctions = templates['Functions'];
delete templates['Functions'];
Object.assign(templates, reportCategories);
templates['Functions'] = reportFunctions;
break;
}
}
@@ -477,25 +769,7 @@
switch (contentType) {
case 'report':
return text
.replaceAll('{{.CampaignName}}', 'Q1 Phishing Simulation')
.replaceAll('{{.CompanyName}}', 'World Corp')
.replaceAll('{{.ReportDate}}', new Date().toLocaleDateString())
.replaceAll('{{.CampaignStartDate}}', '2025-01-01')
.replaceAll('{{.CampaignEndDate}}', '2025-01-31')
.replaceAll('{{.CampaignClosedAt}}', '2025-02-01')
.replaceAll('{{.TotalTargets}}', '120')
.replaceAll('{{.EmailsSent}}', '118')
.replaceAll('{{.EmailsOpened}}', '74')
.replaceAll('{{.ResultClicked}}', '32')
.replaceAll('{{.ResultClickedPercent}}', '27.1')
.replaceAll('{{.ResultSubmitted}}', '14')
.replaceAll('{{.ResultSubmittedPercent}}', '11.9')
.replaceAll('{{.ResultReported}}', '8')
.replaceAll('{{.ResultReportedPercent}}', '6.8')
.replaceAll('{{.OpenedOfSent}}', '62.7')
.replaceAll('{{.ClickedOfOpened}}', '43.2')
.replaceAll('{{.SubmittedOfClicked}}', '43.8');
return renderReportPreview(text);
case 'domain':
return text.replaceAll('{{.BaseURL}}', _baseURL);
case 'page':
@@ -7,7 +7,7 @@
<tr class="text-center bg-pleasant-gray dark:bg-gray-800/40 transition-colors duration-200">
<td class="p-24" {colspan}>
{#if page === 1}
{#if page === 1 || page == null}
<p class="text-lg text-gray-600 dark:text-gray-200 transition-colors duration-200">
No {plural} found
</p>
+79 -28
View File
@@ -100,6 +100,9 @@
}
];
// force data collection off when anonymous, without overwriting the user's saved
// choices in case they toggle anonymous back off
const filteringOptions = [
{
label: 'None',
@@ -485,7 +488,7 @@
contraintEndTime: null,
saveSubmittedData: true,
saveBrowserMetadata: false,
isAnonymous: null,
isAnonymous: false,
isTest: false,
obfuscate: false,
selectedCount: 0,
@@ -899,8 +902,8 @@
closeAt: closeAtUTC,
anonymizeAt: anonymizeAtUTC,
dataAnonymizeAt: dataAnonymizeAtUTC,
saveSubmittedData: formValues.saveSubmittedData,
saveBrowserMetadata: formValues.saveBrowserMetadata,
saveSubmittedData: formValues.isAnonymous ? false : formValues.saveSubmittedData,
saveBrowserMetadata: formValues.isAnonymous ? false : formValues.saveBrowserMetadata,
isAnonymous: formValues.isAnonymous,
isTest: formValues.isTest,
obfuscate: formValues.obfuscate,
@@ -974,8 +977,8 @@
sortField: sortField.byKey(formValues.sortField),
sortOrder: sortOrder.byKey(formValues.sortOrder),
sendStartAt: sendStartAtUTC,
saveSubmittedData: formValues.saveSubmittedData,
saveBrowserMetadata: formValues.saveBrowserMetadata,
saveSubmittedData: formValues.isAnonymous ? false : formValues.saveSubmittedData,
saveBrowserMetadata: formValues.isAnonymous ? false : formValues.saveBrowserMetadata,
isAnonymous: formValues.isAnonymous,
isTest: formValues.isTest,
obfuscate: formValues.obfuscate,
@@ -1143,7 +1146,7 @@
contraintEndTime: null,
saveSubmittedData: true,
saveBrowserMetadata: false,
isAnonymous: null,
isAnonymous: false,
isTest: false,
obfuscate: false,
selectedCount: 0,
@@ -1260,7 +1263,7 @@
dataAnonymizeAt: copyMode ? null : campaign.dataAnonymizeAt,
saveSubmittedData: campaign.saveSubmittedData,
saveBrowserMetadata: campaign.saveBrowserMetadata ?? false,
isAnonymous: campaign.isAnonymous,
isAnonymous: campaign.isAnonymous ?? false,
isTest: campaign.isTest,
obfuscate: campaign.obfuscate || false,
template: templateMap.byKey(campaign.templateID),
@@ -2145,6 +2148,14 @@
>Close Campaign</DateTimeField
>
{#if formValues.isAnonymous}
<div
class="bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md text-sm text-blue-700 dark:text-blue-200 transition-colors duration-200"
>
<strong>Auto-anonymized at close.</strong><br /> Set a date only to anonymize
earlier.
</div>
{/if}
<DateTimeField
bind:value={formValues.anonymizeAt}
min={formValues.closeAt
@@ -2157,17 +2168,19 @@
>Anonymize All Data</DateTimeField
>
<DateTimeField
bind:value={formValues.dataAnonymizeAt}
min={formValues.closeAt
? new Date(formValues.closeAt)
: formValues.sendEndAt
? new Date(formValues.sendEndAt)
: new Date()}
optional
toolTipText="When reached, only the submitted data, user agent, ip and browser metadata are anonymized. The recipient relation is kept."
>Anonymize Data</DateTimeField
>
{#if !formValues.isAnonymous}
<DateTimeField
bind:value={formValues.dataAnonymizeAt}
min={formValues.closeAt
? new Date(formValues.closeAt)
: formValues.sendEndAt
? new Date(formValues.sendEndAt)
: new Date()}
optional
toolTipText="When reached, only the submitted data, user agent, ip and browser metadata are anonymized. The recipient relation is kept."
>Anonymize Data</DateTimeField
>
{/if}
{/if}
</div>
</div>
@@ -2189,24 +2202,49 @@
<div class="mb-6">
<SelectSquare
optional
toolTipText="Consider privacy when saving data."
label="Save submitted data?"
disabled={modalMode === 'update'}
toolTipText="No names, emails, IPs or entered data are stored."
label="Anonymous campaign?"
options={saveSubbmitedDataOptions}
bind:value={formValues.saveSubmittedData}
bind:value={formValues.isAnonymous}
/>
{#if modalMode === 'update'}
<p class="text-xs text-slate-500 dark:text-gray-400">
Anonymous mode is fixed once the campaign is created and cannot be changed.
</p>
{/if}
</div>
<ConditionalDisplay show="blackbox">
{#if formValues.isAnonymous}
<div
class="mb-6 bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md text-sm text-blue-700 dark:text-blue-200 transition-colors duration-200"
>
<strong>Data collection disabled.</strong><br /> Submitted data and browser metadata
are never stored for anonymous campaigns.
</div>
{:else}
<div class="mb-6">
<SelectSquare
optional
toolTipText="Saves JA4 fingerprint, Sec-CH-UA-Platform header, and Accept-Language header."
label="Save browser metadata?"
toolTipText="Consider privacy when saving data."
label="Save submitted data?"
options={saveSubbmitedDataOptions}
bind:value={formValues.saveBrowserMetadata}
bind:value={formValues.saveSubmittedData}
/>
</div>
</ConditionalDisplay>
<ConditionalDisplay show="blackbox">
<div class="mb-6">
<SelectSquare
optional
toolTipText="Saves JA4 fingerprint, Sec-CH-UA-Platform header, and Accept-Language header."
label="Save browser metadata?"
options={saveSubbmitedDataOptions}
bind:value={formValues.saveBrowserMetadata}
/>
</div>
</ConditionalDisplay>
{/if}
{#if !showAdvancedOptionsStep4}
<div class="mt-4">
@@ -2717,15 +2755,28 @@
</span>
</ConditionalDisplay>
<span class="text-grayblue-dark font-medium">Anonymous:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.isAnonymous ? 'Enabled' : 'Disabled'}</span
>
<span class="text-grayblue-dark font-medium">Save Data:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.saveSubmittedData ? 'Enabled' : 'Disabled'}</span
>{formValues.isAnonymous
? 'Disabled'
: formValues.saveSubmittedData
? 'Enabled'
: 'Disabled'}</span
>
<ConditionalDisplay show="blackbox">
<span class="text-grayblue-dark font-medium">Save Metadata:</span>
<span class="text-pc-darkblue dark:text-white"
>{formValues.saveBrowserMetadata ? 'Enabled' : 'Disabled'}</span
>{formValues.isAnonymous
? 'Disabled'
: formValues.saveBrowserMetadata
? 'Enabled'
: 'Disabled'}</span
>
</ConditionalDisplay>
+236 -14
View File
@@ -118,6 +118,41 @@
trainingStarted: 0,
trainingCompleted: 0
};
// grouped outcome stats by position or department
let groupedStats = [];
let groupedBy = 'position';
// results by group is hidden by default and loaded on demand
let showGroupedStats = false;
let groupedStatsLoading = false;
// only offer results by group when recipients actually carry a position or
// department to group on; determined server-side so pagination cannot hide it
let hasGroupData = false;
// the outcomes shown for a group depend on whether it is a training campaign
const groupOutcomeMetrics = (r) =>
campaign.isTraining
? [r.clicked, r.trainingStarted, r.trainingCompleted]
: [r.clicked, r.submitted, r.reported];
// a withheld outcome comes back as a negative sentinel, and a group merged for
// being too small is flagged suppressed. track whether any row hides an outcome
// so the panel can explain the dashes rather than leaving them unexplained
$: groupedAnyRedacted = groupedStats.some(
(r) => r.suppressed || groupOutcomeMetrics(r).some((v) => v < 0)
);
const setHasGroupData = async () => {
if (!campaign.isAnonymous && !campaign.isTraining) {
hasGroupData = false;
return;
}
try {
const res = await api.campaign.getHasGroupData($page.params.id);
hasGroupData = !!res.data?.hasGroupData;
} catch (e) {
console.error('failed to check group data', e);
hasGroupData = false;
}
};
// @ts-ignore
const recipientTableUrlParams = newTableURLParams({
prefix: 'recipient',
@@ -285,6 +320,12 @@
await setResults();
await setEventType();
await setCampaign();
// after setCampaign so campaign.isAnonymous is known; a cheap flag, not the table
await setHasGroupData();
// results by group is loaded on demand, not here; keep it fresh only if already open
if (showGroupedStats) {
await setGroupedStats();
}
await refreshCampaignRecipients();
await getEvents();
await refreshCampaignEventsSince();
@@ -521,6 +562,42 @@
}
};
const setGroupedStats = async () => {
if (!campaign.isAnonymous && !campaign.isTraining) {
groupedStats = [];
return;
}
groupedStatsLoading = true;
try {
const res = await api.campaign.getGroupedResultStats($page.params.id, groupedBy);
if (!res.success) {
throw res.error;
}
groupedStats = res.data ?? [];
} catch (e) {
console.error('failed to load grouped stats', e);
// clear so a failed dimension switch cannot leave stale rows under the new header
groupedStats = [];
} finally {
groupedStatsLoading = false;
}
};
// reveal the results-by-group section and load it on demand
const showGroupedResults = async () => {
showGroupedStats = true;
await setGroupedStats();
};
/** @param {'position'|'department'} by */
const onChangeGroupedBy = async (by) => {
if (by === groupedBy) {
return;
}
groupedBy = by;
await setGroupedStats();
};
/** @param {string} campaignRecipientID */
const onClickCopyEmailContent = async (campaignRecipientID) => {
try {
@@ -679,6 +756,10 @@
/** @param {string} campaignRecipientID @param {Object} recipient */
const showSendMessageModal = (campaignRecipientID, recipient) => {
// recipient is null for anonymous campaigns, where identity is stripped
if (!recipient) {
return;
}
sendMessageRecipient = {
id: campaignRecipientID,
name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(),
@@ -802,6 +883,10 @@
/** @param {string} campaignRecipientID @param {Object} recipient */
const showSetAsSentModal = (campaignRecipientID, recipient) => {
// recipient is null for anonymous campaigns, where identity is stripped
if (!recipient) {
return;
}
setAsSentRecipient = {
id: campaignRecipientID,
name: `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(),
@@ -818,8 +903,10 @@
const onConfirmSendMessage = async () => {
try {
showIsLoading();
// Check if this is a resend before sending
const isResend = campaignRecipients.find((r) => r.id === sendMessageRecipient.id)?.sentAt;
// Check if this is a resend before sending (sentAt is withheld for anonymous
// campaigns, which expose only the coarse `sent` flag)
const sendMessageRow = campaignRecipients.find((r) => r.id === sendMessageRecipient.id);
const isResend = sendMessageRow?.sentAt || sendMessageRow?.sent;
const res = await api.campaign.sendMessage(sendMessageRecipient.id);
if (!res.success) {
throw res.error;
@@ -1526,6 +1613,10 @@
try {
await setResults();
await setCampaign();
// results by group is on demand; only refresh it if the user opened it
if (showGroupedStats) {
await setGroupedStats();
}
// await refreshCampaignRecipients();
const res = await api.campaign.getAllCampaignRecipients(
@@ -1902,6 +1993,104 @@
</StatsCard>
{/if}
</div>
{#if (campaign.isAnonymous || campaign.isTraining) && hasGroupData}
<div class="mb-6">
<div class="mb-3 flex items-center gap-3">
<SubHeadline>Results by group</SubHeadline>
<button
type="button"
class="text-sm font-medium text-cta-blue hover:text-blue-700 dark:text-highlight-blue dark:hover:text-blue-300 transition-colors duration-200"
on:click={() => (showGroupedStats ? (showGroupedStats = false) : showGroupedResults())}
>{showGroupedStats ? 'Hide' : 'Show'}</button
>
</div>
{#if showGroupedStats}
<div class="mb-4 flex gap-2">
<button
type="button"
class="px-4 py-1.5 text-sm font-medium rounded-md transition-colors duration-200 {groupedBy ===
'position'
? 'bg-cta-blue text-white'
: 'bg-grayblue-light dark:bg-gray-800/60 text-slate-600 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700/60'}"
on:click={() => onChangeGroupedBy('position')}>Position</button
>
<button
type="button"
class="px-4 py-1.5 text-sm font-medium rounded-md transition-colors duration-200 {groupedBy ===
'department'
? 'bg-cta-blue text-white'
: 'bg-grayblue-light dark:bg-gray-800/60 text-slate-600 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700/60'}"
on:click={() => onChangeGroupedBy('department')}>Department</button
>
</div>
{#if campaign.isAnonymous}
<div
class="mb-4 rounded-md bg-grayblue-light dark:bg-gray-800/60 px-4 py-3 text-sm text-slate-600 dark:text-gray-300"
>
Per group outcomes are not shown for anonymous campaigns, so no individual can be
singled out. Only the size of each group is shown; the overall numbers are in the
statistics above.
</div>
{:else if groupedAnyRedacted}
<div
class="mb-4 rounded-md bg-grayblue-light dark:bg-gray-800/60 px-4 py-3 text-sm text-slate-600 dark:text-gray-300"
>
Results shown as a dash (—) are hidden so no individual can be singled out, because
the group is too small to show on its own.
</div>
{/if}
<Table
columns={[
{ column: groupedBy === 'position' ? 'Position' : 'Department', size: 'large' },
{ column: 'Targets', size: 'small' },
{ column: campaign.isTraining ? 'Visited' : 'Clicked', size: 'small' },
{ column: campaign.isTraining ? 'Started' : 'Submitted', size: 'small' },
{ column: campaign.isTraining ? 'Completed' : 'Reported', size: 'small' }
]}
pagination={null}
noSearch
hasActions={false}
plural="groups"
hasData={groupedStats.length > 0}
hasNextPage={false}
isGhost={groupedStatsLoading && groupedStats.length === 0}
>
{#each groupedStats as row}
{@const metrics = campaign.isTraining
? [row.clicked, row.trainingStarted, row.trainingCompleted]
: [row.clicked, row.submitted, row.reported]}
<TableRow title={row.suppressed ? 'Group too small to show outcomes' : ''}>
<TableCell value={row.group} />
<TableCell value={String(row.total)} />
{#if row.suppressed}
<TableCell><span class="text-gray-400 dark:text-gray-500"></span></TableCell>
<TableCell><span class="text-gray-400 dark:text-gray-500"></span></TableCell>
<TableCell><span class="text-gray-400 dark:text-gray-500"></span></TableCell>
{:else}
{#each metrics as val}
<TableCell>
{#if val < 0}
<span class="text-gray-400 dark:text-gray-500"></span>
{:else}
{val}
<span class="text-gray-400 dark:text-gray-500"
>({row.total ? Math.round((val / row.total) * 100) : 0}%)</span
>
{/if}
</TableCell>
{/each}
{/if}
</TableRow>
{/each}
</Table>
{/if}
</div>
{/if}
<div class=" mb-6">
<SubHeadline>Event Timeline</SubHeadline>
<EventTimeline
@@ -2486,7 +2675,7 @@
{/if}
</TableCell>
{/if}
{#if recp?.anonymizedID}
{#if !recp?.recipient}
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
<TableCell value={'anonymized'} />
@@ -2496,7 +2685,7 @@
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.firstName}
{recp.recipient?.firstName ?? ''}
</button>
</TableCell>
<TableCell>
@@ -2504,7 +2693,7 @@
on:click={() => openEventsModal(recp.recipientID)}
class="block w-full py-1 text-left"
>
{recp.recipient.lastName}
{recp.recipient?.lastName ?? ''}
</button>
</TableCell>
<TableCell>
@@ -2528,11 +2717,35 @@
</TableCell>
{/if}
<TableCell>
<EventName eventName={recp?.notableEventName} />
{#if campaign.isAnonymous}
<span
class="text-sm italic text-gray-400 dark:text-gray-500"
title="Per-recipient outcomes are hidden for anonymous campaigns. See Results by group."
>Hidden</span
>
{:else}
<EventName eventName={recp?.notableEventName} />
{/if}
</TableCell>
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{#if campaign.isAnonymous}
<!-- exact per recipient timing is withheld so it cannot be matched against
the anonymized event stream; a coarse sent indicator keeps self-managed usable -->
<TableCell>
<span class="text-sm italic text-gray-400 dark:text-gray-500">Hidden</span>
</TableCell>
<TableCell>
<span
class="text-sm {recp?.sent
? 'text-slate-600 dark:text-gray-200'
: 'text-gray-400 dark:text-gray-500'}">{recp?.sent ? 'Sent' : '—'}</span
>
</TableCell>
<TableCell value={recp?.cancelledAt} isDate />
{:else}
<TableCell value={recp?.sendAt} isDate />
<TableCell value={recp?.sentAt} isDate />
<TableCell value={recp?.cancelledAt} isDate />
{/if}
{#if showLureCodeColumn}
<TableCell value={recp?.lureCode || ''} />
{/if}
@@ -2547,7 +2760,7 @@
/>
<TableDropDownButton
name={recp.sentAt ? `Send message again` : `Send message`}
name={recp.sentAt || recp.sent ? `Send message again` : `Send message`}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
@@ -2560,10 +2773,13 @@
? 'Recipient cancelled'
: recp.sentAt
? `Send message again (last sent: ${new Date(recp.sentAt).toLocaleDateString()})`
: `Send message to recipient`}
: recp.sent
? `Send message again`
: `Send message to recipient`}
on:click={() => showSendMessageModal(recp.id, recp.recipient)}
disabled={!!campaign.closedAt ||
recp.cancelledAt ||
!recp.recipient ||
!!recp.recipient?.scimSoftDeletedAt ||
isContextMismatch()}
/>
@@ -2602,14 +2818,17 @@
? 'Campaign is anonymized'
: !recp.recipient
? 'Recipient not available'
: recp.sentAt
: recp.sentAt || recp.sent
? 'Changing this will break the link already sent to this recipient'
: 'Choose the identifier used in this recipient lure URL'}
on:click={() => showLureCodeModal(recp)}
/>
<TableUpdateButton
name="Copy email content"
disabled={!!campaign.closedAt || !!campaign.anonymizedAt || isContextMismatch()}
disabled={!!campaign.closedAt ||
!!campaign.anonymizedAt ||
!recp.recipient ||
isContextMismatch()}
title={isContextMismatch()
? campaign.companyID
? 'Switch to company view to perform this action'
@@ -2634,7 +2853,10 @@
? 'Campaign is closed'
: ''}
on:click={() => onClickSetEmailSent(recp.id, recp.recipient)}
disabled={!!campaign.closedAt || recp.cancelledAt || isContextMismatch()}
disabled={!!campaign.closedAt ||
recp.cancelledAt ||
!recp.recipient ||
isContextMismatch()}
/>
{/if}
<TableViewButton