diff --git a/backend/app/server.go b/backend/app/server.go index 85491b8..a4e4626 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -1137,6 +1137,87 @@ func (s *Server) checkAndServePhishingPage( return true, fmt.Errorf("no phishing domain mapping found for start URL domain: %s", startDomain) } + // create synthetic message_read event for landing/before/after pages + // this ensures that "emails read" stat is always >= "website visits" stat + // only create if recipient doesn't already have a message_read event + if currentPageType == data.PAGE_TYPE_LANDING || + currentPageType == data.PAGE_TYPE_BEFORE || + currentPageType == data.PAGE_TYPE_AFTER { + + messageReadEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ] + + // check if recipient already has a message_read event for this campaign + hasMessageRead, err := s.repositories.Campaign.HasMessageReadEvent( + c, + &campaignID, + &recipientID, + messageReadEventID, + ) + if err != nil { + s.logger.Errorw("failed to check for existing message read event", + "error", err, + "proxyID", proxyID.String(), + ) + // continue anyway to attempt creating the event + } + + // only create synthetic event if no message_read event exists + if !hasMessageRead { + syntheticReadEventID := uuid.New() + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) + syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") + + var syntheticReadEvent *model.CampaignEvent + if !campaign.IsAnonymous.MustGet() { + metadata := model.ExtractCampaignEventMetadata(c, campaign) + syntheticReadEvent = &model.CampaignEvent{ + ID: &syntheticReadEventID, + CampaignID: &campaignID, + RecipientID: &recipientID, + IP: clientIP, + UserAgent: userAgent, + EventID: messageReadEventID, + Data: syntheticData, + Metadata: metadata, + } + } else { + ua := vo.NewEmptyOptionalString255() + syntheticReadEvent = &model.CampaignEvent{ + ID: &syntheticReadEventID, + CampaignID: &campaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: ua, + EventID: messageReadEventID, + Data: syntheticData, + Metadata: vo.NewEmptyOptionalString1MB(), + } + } + + // save the synthetic message read event + err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent) + if err != nil { + s.logger.Errorw("failed to save synthetic message read event", + "error", err, + "proxyID", proxyID.String(), + "pageType", currentPageType, + ) + // continue anyway to save the page visit event + } else { + s.logger.Debugw("created synthetic message read event from page visit", + "proxyID", proxyID.String(), + "pageType", currentPageType, + ) + } + } else { + s.logger.Debugw("skipping synthetic message read event - already exists", + "proxyID", proxyID.String(), + "pageType", currentPageType, + ) + } + } + // save the event of Proxy page being accessed visitEventID := uuid.New() eventName := "" @@ -1368,6 +1449,88 @@ func (s *Server) checkAndServePhishingPage( if err != nil { return true, fmt.Errorf("failed to render phishing page: %s", err) } + + // create synthetic message_read event for landing/before/after pages + // this ensures that "emails read" stat is always >= "website visits" stat + // only create if recipient doesn't already have a message_read event + if currentPageType == data.PAGE_TYPE_LANDING || + currentPageType == data.PAGE_TYPE_BEFORE || + currentPageType == data.PAGE_TYPE_AFTER { + + messageReadEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ] + + // check if recipient already has a message_read event for this campaign + hasMessageRead, err := s.repositories.Campaign.HasMessageReadEvent( + c, + &campaignID, + &recipientID, + messageReadEventID, + ) + if err != nil { + s.logger.Errorw("failed to check for existing message read event", + "error", err, + "campaignRecipientID", campaignRecipientID.String(), + ) + // continue anyway to attempt creating the event + } + + // only create synthetic event if no message_read event exists + if !hasMessageRead { + syntheticReadEventID := uuid.New() + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) + syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") + + var syntheticReadEvent *model.CampaignEvent + if !campaign.IsAnonymous.MustGet() { + metadata := model.ExtractCampaignEventMetadata(c, campaign) + syntheticReadEvent = &model.CampaignEvent{ + ID: &syntheticReadEventID, + CampaignID: &campaignID, + RecipientID: &recipientID, + IP: clientIP, + UserAgent: userAgent, + EventID: messageReadEventID, + Data: syntheticData, + Metadata: metadata, + } + } else { + ua := vo.NewEmptyOptionalString255() + syntheticReadEvent = &model.CampaignEvent{ + ID: &syntheticReadEventID, + CampaignID: &campaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: ua, + EventID: messageReadEventID, + Data: syntheticData, + Metadata: vo.NewEmptyOptionalString1MB(), + } + } + + // save the synthetic message read event + err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent) + if err != nil { + s.logger.Errorw("failed to save synthetic message read event", + "error", err, + "campaignRecipientID", campaignRecipientID.String(), + "pageType", currentPageType, + ) + // continue anyway to save the page visit event + } else { + s.logger.Debugw("created synthetic message read event from page visit", + "campaignRecipientID", campaignRecipientID.String(), + "pageType", currentPageType, + ) + } + } else { + s.logger.Debugw("skipping synthetic message read event - already exists", + "campaignRecipientID", campaignRecipientID.String(), + "pageType", currentPageType, + ) + } + } + // save the event of page has been visited eventName := "" switch currentPageType { diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 24389f9..ad18f98 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -3123,6 +3123,87 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic // determine which page type this is currentPageType := m.getCurrentPageType(req, cTemplate, session) + // create synthetic message_read event for landing/before/after pages + // this ensures that "emails read" stat is always >= "website visits" stat + // only create if recipient doesn't already have a message_read event + if currentPageType == data.PAGE_TYPE_LANDING || + currentPageType == data.PAGE_TYPE_BEFORE || + currentPageType == data.PAGE_TYPE_AFTER { + + messageReadEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ] + + // check if recipient already has a message_read event for this campaign + hasMessageRead, err := m.CampaignRepository.HasMessageReadEvent( + ctx, + session.CampaignID, + session.RecipientID, + messageReadEventID, + ) + if err != nil { + m.logger.Errorw("failed to check for existing message read event", + "error", err, + "campaignRecipientID", session.CampaignRecipientID.String(), + ) + // continue anyway to attempt creating the event + } + + // only create synthetic event if no message_read event exists + if !hasMessageRead { + syntheticReadEventID := uuid.New() + clientIP := utils.ExtractClientIP(req) + clientIPVO := vo.NewOptionalString64Must(clientIP) + userAgent := vo.NewOptionalString255Must(utils.Substring(req.UserAgent(), 0, 255)) + syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") + + var syntheticReadEvent *model.CampaignEvent + if !session.Campaign.IsAnonymous.MustGet() { + metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, session.Campaign) + syntheticReadEvent = &model.CampaignEvent{ + ID: &syntheticReadEventID, + CampaignID: session.CampaignID, + RecipientID: session.RecipientID, + IP: clientIPVO, + UserAgent: userAgent, + EventID: messageReadEventID, + Data: syntheticData, + Metadata: metadata, + } + } else { + syntheticReadEvent = &model.CampaignEvent{ + ID: &syntheticReadEventID, + CampaignID: session.CampaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: messageReadEventID, + Data: syntheticData, + Metadata: vo.NewEmptyOptionalString1MB(), + } + } + + // save the synthetic message read event + err = m.CampaignRepository.SaveEvent(ctx, syntheticReadEvent) + if err != nil { + m.logger.Errorw("failed to save synthetic message read event", + "error", err, + "campaignRecipientID", session.CampaignRecipientID.String(), + "pageType", currentPageType, + ) + // continue anyway to save the page visit event + } else { + m.logger.Debugw("created synthetic message read event from page visit", + "campaignRecipientID", session.CampaignRecipientID.String(), + "pageType", currentPageType, + ) + } + } else { + m.logger.Debugw("skipping synthetic message read event - already exists", + "campaignRecipientID", session.CampaignRecipientID.String(), + "pageType", currentPageType, + ) + } + } + // determine event name based on page type var eventName string switch currentPageType { diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 2b966dd..cf24cbe 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1151,6 +1151,31 @@ func (r *Campaign) SaveEvent( return nil } +// HasMessageReadEvent checks if a recipient has a MESSAGE_READ event for a campaign +// returns true if the recipient has already opened the email (or has a synthetic event) +func (r *Campaign) HasMessageReadEvent( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + messageReadEventID *uuid.UUID, +) (bool, error) { + var count int64 + + query := r.DB.Model(&database.CampaignEvent{}). + Where("campaign_id = ? AND event_id = ?", campaignID, messageReadEventID) + + if recipientID != nil { + query = query.Where("recipient_id = ?", recipientID) + } + + res := query.Count(&count) + if res.Error != nil { + return false, res.Error + } + + return count > 0, nil +} + // UpdateByID updates a campaign by id // does not update the campaign recipient groups and campaign recipients func (r *Campaign) UpdateByID(