Files
phishingclub/backend/service/campaign.go
Ronni Skansing 95cd1f8a7c fix missing allow deny list on campaign create
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-02-04 21:09:25 +01:00

4724 lines
144 KiB
Go

package service
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand"
"net/url"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"text/template"
"time"
go_errors "github.com/go-errors/errors"
"gopkg.in/yaml.v3"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/build"
"github.com/phishingclub/phishingclub/cache"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/log"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/utils"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"github.com/wneessen/go-mail"
"gorm.io/gorm"
)
// Campaign is the Campaign service
type Campaign struct {
Common
CampaignRepository *repository.Campaign
CampaignRecipientRepository *repository.CampaignRecipient
RecipientRepository *repository.Recipient
RecipientGroupRepository *repository.RecipientGroup
AllowDenyRepository *repository.AllowDeny
WebhookRepository *repository.Webhook
CampaignTemplateService *CampaignTemplate
TemplateService *Template
DomainService *Domain
RecipientService *Recipient
MailService *Email
APISenderService *APISender
SMTPConfigService *SMTPConfiguration
WebhookService *Webhook
AttachmentPath string
}
// Create creates a new campaign
func (c *Campaign) Create(
ctx context.Context,
session *model.Session,
campaign *model.Campaign,
) (*uuid.UUID, error) {
ae := NewAuditEvent("Campaign.Create", session)
// 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
}
if len(campaign.RecipientGroupIDs) == 0 {
return nil, validate.WrapErrorWithField(errors.New("no groups provided"), "Recipient Groups")
}
// set default webhookIncludeData for backward compatibility
if !campaign.WebhookIncludeData.IsSpecified() {
campaign.WebhookIncludeData.Set(model.WebhookDataLevelFull)
}
// set default webhookEvents (0 means all events) for backward compatibility
if !campaign.WebhookEvents.IsSpecified() {
campaign.WebhookEvents.Set(0)
}
// if the schedule type is scheduled, set the start time to start of day and end to the end of the last day
if campaign.ConstraintWeekDays.IsSpecified() && !campaign.ConstraintWeekDays.IsNull() {
if err := campaign.ValidateSendTimesSet(); err != nil {
return nil, errs.Wrap(err)
}
if err := c.updateSchedulesCampaignStartAndEndDates(campaign); err != nil {
return nil, errs.Wrap(err)
}
}
// validate
if err := campaign.Validate(); err != nil {
return nil, errs.Wrap(err)
}
// check the template is usable
templateID := campaign.TemplateID.MustGet()
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&templateID,
&repository.CampaignTemplateOption{
UsableOnly: true,
},
)
if err != nil {
return nil, errs.Wrap(err)
}
if cTemplate == nil {
return nil, errors.New("attempted to create campaign with unusable template")
}
// check uniqueness
var companyID *uuid.UUID
if cid, err := campaign.CompanyID.Get(); err == nil {
companyID = &cid
}
name := campaign.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
c.CampaignRepository.DB,
"campaigns",
name.String(),
companyID,
nil,
)
if err != nil {
c.Logger.Errorw("failed to check campaign uniqueness", "error", err)
return nil, errs.Wrap(err)
}
if !isOK {
c.Logger.Debugw("campaign name is already taken", "error", name.String())
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
// validate allow deny list selections
if err := c.validateAllowDenyIsSameTypeByIDs(ctx, campaign); err != nil {
return nil, errs.Wrap(err)
}
// check there is atleast one valid group
// and remove any empty groups
validGroups := []*uuid.UUID{}
for _, groupID := range campaign.RecipientGroupIDs.MustGet() {
count, err := c.RecipientGroupRepository.GetRecipientCount(ctx, groupID)
if err != nil {
return nil, errs.Wrap(err)
}
if count > 0 {
validGroups = append(validGroups, groupID)
}
}
if len(validGroups) == 0 {
return nil, errs.NewValidationError(
errors.New("Selected groups have no recipients"),
)
}
campaign.RecipientGroupIDs.Set(validGroups)
// save
id, err := c.CampaignRepository.Insert(ctx, campaign)
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
return nil, errs.Wrap(err)
}
createdCampaign, err := c.CampaignRepository.GetByID(
ctx,
id,
&repository.CampaignOption{
WithRecipientGroups: true,
WithAllowDeny: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return nil, errs.Wrap(err)
}
// preserve jitter values from original campaign (not persisted to db)
createdCampaign.JitterMin = campaign.JitterMin
createdCampaign.JitterMax = campaign.JitterMax
err = c.schedule(ctx, session, createdCampaign)
if err != nil {
c.Logger.Errorw("failed to schedule campaign", "error", err)
// TODO we should delete the campaign as it was not scheduled
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
c.AuditLogAuthorized(ae)
return id, nil
}
// schedule campaign schedules the campaign
// this is a service method that does not perform auth, use with consideration
// applyJitter applies random jitter to a time based on jitter min/max in minutes
// if min is negative of max (symmetric), uses uniform distribution from min to max
// e.g., min=-10, max=10 means randomly offset anywhere from -10 to +10 minutes
// clamps the result to stay within startBound and endBound
func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBound time.Time) time.Time {
if jitterMin == 0 && jitterMax == 0 {
return baseTime
}
var randomJitter int
// check if symmetric jitter (min = -max)
if jitterMin == -jitterMax {
// symmetric: uniform distribution from -max to +max
randomJitter = rand.Intn(2*jitterMax+1) - jitterMax
} else {
// asymmetric: generate random jitter between min and max
jitterRange := jitterMax - jitterMin
if jitterRange > 0 {
randomJitter = jitterMin + rand.Intn(jitterRange+1)
} else {
randomJitter = jitterMin
}
}
jitteredTime := baseTime.Add(time.Duration(randomJitter) * time.Minute)
// clamp to campaign bounds
if jitteredTime.Before(startBound) {
return startBound
}
if jitteredTime.After(endBound) {
return endBound
}
return jitteredTime
}
func (c *Campaign) schedule(
ctx context.Context,
session *model.Session,
campaign *model.Campaign,
) error {
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.Logger.Errorw("failed to create campaign", "error", err)
return errs.Wrap(err)
}
if !isAuthorized {
return errs.ErrAuthorizationFailed
}
// get all recipients and remove duplicates
recipients := []*model.Recipient{}
dubMap := map[string]bool{}
if campaign.RecipientGroupIDs.IsSpecified() && !campaign.RecipientGroupIDs.IsNull() {
for _, groupID := range campaign.RecipientGroupIDs.MustGet() {
group, err := c.RecipientGroupRepository.GetByID(
ctx,
groupID,
&repository.RecipientGroupOption{
WithRecipients: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get recipient group by id", "error", err)
return errs.Wrap(err)
}
recps := group.Recipients
if recps == nil {
c.Logger.Error("recipient group did not load recipients")
return errors.New("recipient group did not load recipients")
}
// collect all and remove duplicates
for _, recp := range recps {
id := recp.ID.MustGet().String()
if _, ok := dubMap[id]; ok {
continue
}
dubMap[id] = true
recipients = append(recipients, recp)
}
}
}
// handle self managed campaign
// if this is a self-managed campaign, we must not remove existing
// campaign-recipients when rescheduling as this would give them new IDs
// which would mean previous sent links will not work anymore.
// instead we must only add new recipients and remove the ones that are no longer in the recipient groups
if campaign.IsSelfManaged() {
if err := campaign.ValidateNoSendTimesSet(); err != nil {
return errs.Wrap(err)
}
// sort by email when self managed
sort.Slice(recipients, func(a, b int) bool {
if v, err := recipients[a].Email.Get(); err == nil {
if v2, err := recipients[b].Email.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
})
campaignID := campaign.ID.MustGet()
// remove campaign-recipients that are not supplied in a re-schedule
recipientIDs := make([]*uuid.UUID, len(recipients))
for i, recp := range recipients {
rid := recp.ID.MustGet()
recipientIDs[i] = &rid
}
// c.Logger.Debugw("keeping recpient IDs", recipientIDs)
err := c.CampaignRecipientRepository.DeleteRecipientsNotIn(
ctx,
&campaignID,
recipientIDs,
)
if err != nil {
c.Logger.Errorw("failed to delete campaign recipients", "error", err)
return errs.Wrap(err)
}
// insert campaign-recipients that are not already in the schedule
campaignRecipients := make([]*model.CampaignRecipient, len(recipients))
for i, recipient := range recipients {
// check if already exists
rid := recipient.ID.MustGet()
_, err := c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
ctx,
&campaignID,
&rid,
&repository.CampaignRecipientOption{},
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to get campaign recipient by campaign and recipient id",
"error", err,
)
return errs.Wrap(err)
}
// if exists, skip it
if !errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
recpID := nullable.NewNullableWithValue(recipient.ID.MustGet())
campaignRecipients[i] = &model.CampaignRecipient{
RecipientID: recpID,
CampaignID: nullable.NewNullableWithValue(campaignID),
SelfManaged: nullable.NewNullableWithValue(true),
}
// save campaign-recipient
_, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i])
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
return errs.Wrap(err)
}
}
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_SELF_MANAGED,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
return nil
}
if err := campaign.ValidateSendTimesSet(); err != nil {
return errs.Wrap(err)
}
// set schedule with a even spread between startAt and end time
startAt := campaign.SendStartAt.MustGet()
endAt := campaign.SendEndAt.MustGet()
recipientsCount := len(recipients)
// no recipients, no schedule
if recipientsCount == 0 {
return fmt.Errorf("no recipients to schedule")
}
campaignRecipients := make([]*model.CampaignRecipient, recipientsCount)
// sort the recipients by the selected sort field and order
sortOrder := campaign.SortOrder.MustGet().String()
sortField := campaign.SortField.MustGet().String()
recipients = sortRecipients(recipients, sortOrder, sortField)
// schedule the emails
if recipientsCount == 0 {
return fmt.Errorf("no recipients to schedule for '%s'", campaign.Name.MustGet())
}
scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED]
// get jitter values if specified
jitterMin := 0
jitterMax := 0
if campaign.JitterMin.IsSpecified() && !campaign.JitterMin.IsNull() {
jitterMin = campaign.JitterMin.MustGet()
}
if campaign.JitterMax.IsSpecified() && !campaign.JitterMax.IsNull() {
jitterMax = campaign.JitterMax.MustGet()
}
c.Logger.Debugw("scheduling campaign with jitter",
"campaignName", campaign.Name.MustGet(),
"jitterMin", jitterMin,
"jitterMax", jitterMax,
)
// handle single recipient
if recipientsCount == 1 {
recpID := nullable.NewNullableWithValue(recipients[0].ID.MustGet())
campaignID := nullable.NewNullableWithValue(campaign.ID.MustGet())
jitteredStartAt := applyJitter(startAt, jitterMin, jitterMax, startAt, endAt)
campaignRecipient := &model.CampaignRecipient{
RecipientID: recpID,
CampaignID: campaignID,
SendAt: nullable.NewNullableWithValue(jitteredStartAt),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
return errs.Wrap(err)
}
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_SCHEDULED,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
return nil
}
// handle delivery schedule with specific week days
// it is when there a start and end date, specific weekdays and times ranges to send in.
if campaign.ConstraintWeekDays.IsSpecified() && !campaign.ConstraintWeekDays.IsNull() {
c.Logger.Debugw("schedule campaign with contraints",
"campaignName", campaign.Name.MustGet(),
)
campaignDuration := time.Duration(0)
// first we calculate the diff, so we know the offset for we need to push between each in range moment
currentDate := startAt
toFind := campaign.ConstraintWeekDays.MustGet().AsSlice()
// iterate over each day in the period
for currentDate.Before(endAt) || currentDate.Equal(endAt) {
// check if the current day is in the week days
if slices.Contains(toFind, int(currentDate.Weekday())) {
// calculate the number of minutes the campaigns spans on this week day
dayStartTime := campaign.ConstraintStartTime.MustGet()
dayEndTime := campaign.ConstraintEndTime.MustGet()
diff := dayStartTime.DiffMinutes(dayEndTime)
campaignDuration += diff
}
currentDate = currentDate.AddDate(0, 0, 1)
}
// interval is the minutes between each recipient schedule
//interval := int(campaignDuration.Minutes()) / (recipientsCount - 1) // -1 as the first send it placed at the start time
interval := time.Duration(campaignDuration.Nanoseconds() / int64(recipientsCount-1))
dayStartTime := campaign.ConstraintStartTime.MustGet()
dayEndTime := campaign.ConstraintEndTime.MustGet()
// schedule each recipient
// iterate through the time again and progess on each interval until all recipients are set
currentDate = startAt
c.Logger.Debugw("campaign interval", "interval", interval)
for currentDate.Before(endAt) || currentDate.Equal(endAt) {
c.Logger.Debugw("schedule check date", "currentDate", currentDate)
// check if the current day is in the week days
if slices.Contains(toFind, int(currentDate.Weekday())) {
c.Logger.Debugw("scheduling date", "currentDate", currentDate)
// iterate over the hours in the day and jump each interval
// if over the end time, break and skip to next day, saving the surplus of interval minutes
// to be added to next send
dayConstraintStart := currentDate.Truncate(24 * time.Hour).Add(dayStartTime.Minutes())
dayConstraintEnd := currentDate.Truncate(24 * time.Hour).Add(dayEndTime.Minutes())
currentDayStart := dayConstraintStart
for currentDayStart.Before(dayConstraintEnd) || currentDayStart.Equal(dayConstraintEnd) {
c.Logger.Debugw("scheduling date at", "currentDayStart", currentDayStart)
// check if we have any recipients left
if len(recipients) == 0 {
break
}
// get the next recipient
recipient := recipients[0]
recipients = recipients[1:]
// apply jitter to send time, clamped to daily constraint bounds
jitteredTime := applyJitter(currentDayStart, jitterMin, jitterMax, dayConstraintStart, dayConstraintEnd)
// save
campaignRecipient := &model.CampaignRecipient{
RecipientID: recipient.ID,
CampaignID: campaign.ID,
SendAt: nullable.NewNullableWithValue(jitteredTime),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
_, err := c.CampaignRecipientRepository.Insert(ctx, campaignRecipient)
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
return errs.Wrap(err)
}
// check if we are over the end time
currentDayStart = currentDayStart.Add(interval * time.Duration(1))
}
}
// check the next day within the start and end date range
currentDate = currentDate.AddDate(0, 0, 1)
}
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_SCHEDULED,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
return nil
}
// handle basic delivery schedule
// it is when there is no constraints, equal distribution between start and end datetime
campaignDuration := endAt.Sub(startAt)
// Calculate interval between emails
// TODO make this work in minutes
interval := time.Duration(campaignDuration.Nanoseconds() / int64(recipientsCount-1))
for i, recipient := range recipients {
// calculate base time from original schedule, not previous jittered time
baseTime := startAt.Add(time.Duration(i) * interval)
// apply jitter to send time
jitteredSentAt := applyJitter(baseTime, jitterMin, jitterMax, startAt, endAt)
// todo perhaps this array is unnecesssary
//recpID := recipient.ID.MustGet()
//campaignID := campaign.ID.MustGet()
campaignRecipients[i] = &model.CampaignRecipient{
RecipientID: recipient.ID,
CampaignID: campaign.ID,
SendAt: nullable.NewNullableWithValue(jitteredSentAt),
NotableEventID: nullable.NewNullableWithValue(*scheduledEvent),
}
// save
_, err = c.CampaignRecipientRepository.Insert(ctx, campaignRecipients[i])
if err != nil {
c.Logger.Errorw("failed to create campaign", "error", err)
return errs.Wrap(err)
}
}
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_SCHEDULED,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
return nil
}
// sortRecipients sorts the recipients by the selected sort field and order
func sortRecipients(recipients []*model.Recipient, sortOrder, sortField string) []*model.Recipient {
switch sortOrder {
case "random":
sort.Slice(recipients, func(i, j int) bool {
// return a random bool
// #nosec
return rand.Float32() < 0.5
})
case "desc":
sort.Slice(recipients, func(a, b int) bool {
// TODO implements the rest of the fields
switch sortField {
case "email":
if v, err := recipients[a].Email.Get(); err == nil {
if v2, err := recipients[b].Email.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "first_name":
if v, err := recipients[a].FirstName.Get(); err == nil {
if v2, err := recipients[b].FirstName.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "last_name":
if v, err := recipients[a].LastName.Get(); err == nil {
if v2, err := recipients[b].LastName.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "phone":
if v, err := recipients[a].Phone.Get(); err == nil {
if v2, err := recipients[b].Phone.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "position":
if v, err := recipients[a].Position.Get(); err == nil {
if v2, err := recipients[b].Position.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "department":
if v, err := recipients[a].Department.Get(); err == nil {
if v2, err := recipients[b].Department.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "city":
if v, err := recipients[a].City.Get(); err == nil {
if v2, err := recipients[b].City.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "country":
if v, err := recipients[a].Country.Get(); err == nil {
if v2, err := recipients[b].Country.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "misc":
if v, err := recipients[a].Misc.Get(); err == nil {
if v2, err := recipients[b].Misc.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
case "extraID":
if v, err := recipients[a].ExtraIdentifier.Get(); err == nil {
if v2, err := recipients[b].ExtraIdentifier.Get(); err == nil {
return strings.ToLower(v.String()) > strings.ToLower(v2.String())
}
}
return false
default:
panic("unknown sort field")
}
})
case "asc":
sort.Slice(recipients, func(a, b int) bool {
switch sortField {
case "email":
if v, err := recipients[a].Email.Get(); err == nil {
if v2, err := recipients[b].Email.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "firstName":
if v, err := recipients[a].FirstName.Get(); err == nil {
if v2, err := recipients[b].FirstName.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "lastName":
if v, err := recipients[a].LastName.Get(); err == nil {
if v2, err := recipients[b].LastName.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "phone":
if v, err := recipients[a].Phone.Get(); err == nil {
if v2, err := recipients[b].Phone.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "position":
if v, err := recipients[a].Position.Get(); err == nil {
if v2, err := recipients[b].Position.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "department":
if v, err := recipients[a].Department.Get(); err == nil {
if v2, err := recipients[b].Department.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "city":
if v, err := recipients[a].City.Get(); err == nil {
if v2, err := recipients[b].City.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "country":
if v, err := recipients[a].Country.Get(); err == nil {
if v2, err := recipients[b].Country.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "misc":
if v, err := recipients[a].Misc.Get(); err == nil {
if v2, err := recipients[b].Misc.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
case "extraID":
if v, err := recipients[a].ExtraIdentifier.Get(); err == nil {
if v2, err := recipients[b].ExtraIdentifier.Get(); err == nil {
return strings.ToLower(v.String()) < strings.ToLower(v2.String())
}
}
return false
default:
panic("unknown sort field")
}
})
}
return recipients
}
// GetByID gets a campaign by its id
func (c *Campaign) GetByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
options *repository.CampaignOption,
) (*model.Campaign, error) {
ae := NewAuditEvent("Campaign.GetById", session)
ae.Details["id"] = id.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
}
campaign, err := c.CampaignRepository.GetByID(ctx, id, options)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return campaign, nil
}
// GetByName gets a campaign by its name
func (c *Campaign) GetByName(
ctx context.Context,
session *model.Session,
name string,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Campaign, error) {
ae := NewAuditEvent("Campaign.GetByName", session)
ae.Details["name"] = name
// 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
}
campaign, err := c.CampaignRepository.GetByNameAndCompanyID(ctx, name, companyID, options)
if err != nil {
c.Logger.Errorw("failed to get campaign by name", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return campaign, nil
}
// GetByCompanyID gets a campaigns by it company id
func (c *Campaign) GetByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
ae := NewAuditEvent("Campaign.GetByCompanyID", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAllByCompanyID(ctx, companyID, options)
if err != nil {
c.Logger.Errorw("failed to get campaigns by company id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetStats gets stats for a campaign
// if no company id is given it retrieves stats for global including all companies
func (c *Campaign) GetStats(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
includeTestCampaigns bool,
) (*model.CampaignsStatView, error) {
ae := NewAuditEvent("Campaign.GetStats", session)
if companyID != nil {
ae.Details["companyID"] = companyID.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
}
// get stats
active, err := c.CampaignRepository.GetActiveCount(ctx, companyID, includeTestCampaigns)
if err != nil {
return nil, errs.Wrap(err)
}
upcoming, err := c.CampaignRepository.GetUpcomingCount(ctx, companyID, includeTestCampaigns)
if err != nil {
return nil, errs.Wrap(err)
}
finished, err := c.CampaignRepository.GetFinishedCount(ctx, companyID, includeTestCampaigns)
if err != nil {
return nil, errs.Wrap(err)
}
return &model.CampaignsStatView{
Active: active,
Upcoming: upcoming,
Finished: finished,
}, nil
}
// GetResultStats gets results stats for a campaign
func (c *Campaign) GetResultStats(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
) (*model.CampaignResultView, error) {
ae := NewAuditEvent("Campaign.GetResultStats", 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
}
// get stats
stats, err := c.CampaignRepository.GetResultStats(ctx, campaignID)
if err != nil {
c.Logger.Errorw("failed to get campaign results statistics", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return stats, nil
}
// GetRecipientsByCampaignID gets all recipients for a campaign
func (c *Campaign) GetRecipientsByCampaignID(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
options *repository.CampaignRecipientOption,
) (*model.Result[model.CampaignRecipient], error) {
ae := NewAuditEvent("Campaign.GetRecipientsByCampaignID", session)
ae.Details["campaignId"] = campaignID.String()
result := model.NewEmptyResult[model.CampaignRecipient]()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get all recipients
if options.OrderBy == "" {
options.OrderBy = "campaign_recipients.sent_at"
}
result, err = c.CampaignRecipientRepository.GetByCampaignID(
ctx,
campaignID,
options,
)
if err != nil {
c.Logger.Errorw("failed to get recipients by campaign id", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetEventsByCampaignID gets all events for a campaign
func (c *Campaign) GetEventsByCampaignID(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
queryArgs *vo.QueryArgs,
since *time.Time,
eventTypeIDs []string,
) (*model.Result[model.CampaignEvent], error) {
result := model.NewEmptyResult[model.CampaignEvent]()
ae := NewAuditEvent("Campaign.GetEventsByCampaignID", 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 result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetEventsByCampaignID(
ctx,
campaignID,
&repository.CampaignEventOption{
QueryArgs: queryArgs,
WithUser: true,
EventTypeIDs: eventTypeIDs,
},
since,
)
if err != nil {
c.Logger.Errorw("failed to get events by campaign id", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetAll gets all campaigns using pagination
func (c *Campaign) GetAll(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
ae := NewAuditEvent("Campaign.GetAll", session)
if companyID != nil {
ae.Details["companyID"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAll(
ctx,
companyID,
options,
)
if err != nil {
c.Logger.Errorw("failed to get all campaigns", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetAllWithinDates gets all campaigns active, scheduled or self managed within dates
func (c *Campaign) GetAllWithinDates(
ctx context.Context,
session *model.Session,
startDate time.Time,
endDate time.Time,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
ae := NewAuditEvent("Campaign.GetAllWithinDates", session)
if companyID != nil {
ae.Details["companyID"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAllCampaignWithinDates(
ctx,
companyID,
startDate,
endDate,
options,
)
if err != nil {
c.Logger.Errorw("failed to get all campaigns between dates", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetAllActive gets all active campaigns using pagination
func (c *Campaign) GetAllActive(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
ae := NewAuditEvent("Campaign.GetAllActive", session)
if companyID != nil {
ae.Details["companyID"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAllActive(
ctx,
companyID,
options,
)
if err != nil {
c.Logger.Errorw("failed to get all active campaigns", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetAllUpcoming gets all upcoming campaigns using pagination
func (c *Campaign) GetAllUpcoming(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
ae := NewAuditEvent("Campaign.GetAllUpcoming", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAllUpcoming(
ctx,
companyID,
options,
)
if err != nil {
c.Logger.Errorw("failed to get all upcoming campaigns", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetAllFinished gets all finished campaigns using pagination
func (c *Campaign) GetAllFinished(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
options *repository.CampaignOption,
) (*model.Result[model.Campaign], error) {
result := model.NewEmptyResult[model.Campaign]()
ae := NewAuditEvent("Campaign.GetAllFinished", session)
if companyID != nil {
ae.Details["companyId"] = companyID.String()
}
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = c.CampaignRepository.GetAllFinished(
ctx,
companyID,
options,
)
if err != nil {
c.Logger.Errorw("failed to get all finished campaigns", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// SaveTrackingPixelLoaded saves the tracking pixel event for a campaign recipient
// no permissions to check - this endpoint is public
// only a campaign recipient id is required
func (c *Campaign) SaveTrackingPixelLoaded(
ctx *gin.Context,
campaignRecipientID *uuid.UUID,
) error {
// get the campaign campaignRecipient
campaignRecipient, err := c.CampaignRecipientRepository.GetByCampaignRecipientID(
ctx.Request.Context(),
campaignRecipientID,
)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Debugw("campaign recipient not found for tracking pixel", "campaign_recipient_id", campaignRecipientID.String())
return err
}
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return errs.Wrap(err)
}
recipientID := campaignRecipient.RecipientID.MustGet()
campaignID := campaignRecipient.CampaignID.MustGet()
campaign, err := c.CampaignRepository.GetByID(
ctx,
&campaignID,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return errs.Wrap(err)
}
trackingPixelLoadedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ]
newEventID := uuid.New()
var campaignEvent *model.CampaignEvent
if campaign.IsAnonymous.MustGet() {
userAgent := vo.NewEmptyOptionalString255()
campaignEvent = &model.CampaignEvent{
ID: &newEventID,
CampaignID: &campaignID,
RecipientID: nil,
IP: vo.NewEmptyOptionalString64(),
UserAgent: userAgent,
EventID: trackingPixelLoadedEventID,
Data: vo.NewEmptyOptionalString1MB(),
Metadata: vo.NewEmptyOptionalString1MB(),
}
} else {
ip := vo.NewOptionalString64Must(utils.ExtractClientIP(ctx.Request))
ua := ctx.Request.UserAgent()
if len(ua) > 255 {
ua = strings.TrimSpace(ua[:255])
}
userAgent := vo.NewOptionalString255Must(ua)
// extract metadata (ja4, platform, accept-language)
metadata := model.ExtractCampaignEventMetadata(ctx, campaign)
campaignEvent = &model.CampaignEvent{
ID: &newEventID,
CampaignID: &campaignID,
RecipientID: &recipientID,
IP: ip,
UserAgent: userAgent,
EventID: cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ],
Data: vo.NewEmptyOptionalString1MB(),
Metadata: metadata,
}
}
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
c.Logger.Errorw("failed to save tracking pixel loaded event", "error", err)
return errs.Wrap(err)
}
// handle most notable event
err = c.SetNotableCampaignRecipientEvent(
ctx,
campaignRecipient,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ,
)
if err != nil {
// logging was done in the previous call
return errs.Wrap(err)
}
// handle webhook
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
return errs.Wrap(err)
}
if errors.Is(err, gorm.ErrRecordNotFound) || webhookID == nil {
return nil
}
err = c.HandleWebhook(
ctx,
webhookID,
&campaignID,
&recipientID,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ,
nil,
)
if err != nil {
return errs.Wrap(err)
}
return nil
}
// UpdateByID updates a campaign by id
func (c *Campaign) UpdateByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
incoming *model.Campaign,
) error {
ae := NewAuditEvent("Campaign.UpdateById", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if len(incoming.RecipientGroupIDs) == 0 {
return validate.WrapErrorWithField(errors.New("no groups provided"), "Recipient Groups")
}
// if the schedule type is scheduled, set the start time to start of day and end to the end of the last day
if incoming.ConstraintWeekDays.IsSpecified() && !incoming.ConstraintWeekDays.IsNull() {
if err := incoming.ValidateScheduledTimes(); err != nil {
return errs.Wrap(err)
}
if err := c.updateSchedulesCampaignStartAndEndDates(incoming); err != nil {
return errs.Wrap(err)
}
}
// validate allow deny list selections
if err := c.validateAllowDenyIsSameTypeByIDs(ctx, incoming); err != nil {
return errs.Wrap(err)
}
// get campaign and change the values
current, err := c.CampaignRepository.GetByID(
ctx,
id,
&repository.CampaignOption{
WithRecipientGroups: true,
},
)
if err != nil {
c.Logger.Errorw("failed to campaign by id", "error", err)
return errs.Wrap(err)
}
// check if the campaign is within the allowed time frame for editing
// we allow editing up until 5 minutes before the campaigns start time
if sendStartAt, err := current.SendStartAt.Get(); err == nil {
nowPlus5 := time.Now().Add(5 * time.Minute)
// c.Logger.Debugw("now (+5min): %s campaign: %s", nowPlus5.String(), sendStartAt.String())
if nowPlus5.After(sendStartAt) {
c.Logger.Debugw(
"campaign too close to start to edit",
"campaignID", current.ID.MustGet().String(),
"nowPlus5", nowPlus5,
"sendStartAt", sendStartAt,
)
return validate.WrapErrorWithField(
errors.New("Campaign already started or too close to start time"),
"Not allowed",
)
}
}
// update the values
if v, err := incoming.Name.Get(); err == nil {
// check uniqueness
var companyID *uuid.UUID
if cid, err := incoming.CompanyID.Get(); err == nil {
companyID = &cid
}
name := incoming.Name.MustGet()
isOK, err := repository.CheckNameIsUnique(
ctx,
c.CampaignRepository.DB,
"campaigns",
name.String(),
companyID,
id,
)
if err != nil {
c.Logger.Errorw("failed to check campaign uniqueness", "error", err)
return errs.Wrap(err)
}
if !isOK {
c.Logger.Debugw("campaign name not unique", "name", name.String())
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
}
current.Name.Set(v)
}
// update values
if v, err := incoming.SaveSubmittedData.Get(); err == nil {
current.SaveSubmittedData.Set(v)
}
if v, err := incoming.SaveBrowserMetadata.Get(); err == nil {
current.SaveBrowserMetadata.Set(v)
}
if v, err := incoming.IsAnonymous.Get(); err == nil {
current.IsAnonymous.Set(v)
}
if v, err := incoming.IsTest.Get(); err == nil {
current.IsTest.Set(v)
}
if v, err := incoming.Obfuscate.Get(); err == nil {
current.Obfuscate.Set(v)
}
if v, err := incoming.WebhookIncludeData.Get(); err == nil {
current.WebhookIncludeData.Set(v)
}
if v, err := incoming.WebhookEvents.Get(); err == nil {
current.WebhookEvents.Set(v)
}
if v, err := incoming.TemplateID.Get(); err == nil {
current.TemplateID.Set(v)
}
if v, err := incoming.SortField.Get(); err == nil {
current.SortField.Set(v)
}
if v, err := incoming.SortOrder.Get(); err == nil {
current.SortOrder.Set(v)
}
if v, err := incoming.SendStartAt.Get(); err == nil {
current.SendStartAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.SendEndAt.Get(); err == nil {
current.SendEndAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.ConstraintWeekDays.Get(); err == nil {
current.ConstraintWeekDays.Set(v)
}
if v, err := incoming.ConstraintStartTime.Get(); err == nil {
current.ConstraintStartTime.Set(v)
}
if v, err := incoming.ConstraintEndTime.Get(); err == nil {
current.ConstraintEndTime.Set(v)
}
if v, err := incoming.CloseAt.Get(); err == nil {
current.CloseAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.AnonymizeAt.Get(); err == nil {
current.AnonymizeAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.ClosedAt.Get(); err == nil {
current.ClosedAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.AnonymizedAt.Get(); err == nil {
current.AnonymizedAt.Set(v.Truncate(time.Minute))
}
if v, err := incoming.TemplateID.Get(); err == nil {
current.TemplateID.Set(v)
}
if v, err := incoming.RecipientGroupIDs.Get(); err == nil {
current.RecipientGroupIDs.Set(v)
}
// handle webhook ID
if incoming.WebhookID.IsSpecified() {
if incoming.WebhookID.IsNull() {
current.WebhookID.SetNull()
} else {
current.WebhookID.Set(incoming.WebhookID.MustGet())
}
}
// check there is atleast one valid group
// and remove any empty groups
validGroups := []*uuid.UUID{}
for _, groupID := range current.RecipientGroupIDs.MustGet() {
count, err := c.RecipientGroupRepository.GetRecipientCount(ctx, groupID)
if err != nil {
return errs.Wrap(err)
}
if count > 0 {
validGroups = append(validGroups, groupID)
}
}
if len(validGroups) == 0 {
return errs.NewValidationError(
errors.New("Selected groups have no recipients"),
)
}
// overwrite the allow / deny
current.AllowDenyIDs = incoming.AllowDenyIDs
if _, err := current.AllowDenyIDs.Get(); err == nil {
if incoming.DenyPageID.IsSpecified() {
if incoming.DenyPageID.IsNull() {
current.DenyPageID.SetNull()
} else {
current.DenyPageID.Set(incoming.DenyPageID.MustGet())
}
}
}
// handle evasion page ID
if incoming.EvasionPageID.IsSpecified() {
if incoming.EvasionPageID.IsNull() {
current.EvasionPageID.SetNull()
} else {
current.EvasionPageID.Set(incoming.EvasionPageID.MustGet())
}
}
// validate and update
if err := current.Validate(); err != nil {
return errs.Wrap(err)
}
err = c.CampaignRepository.UpdateByID(ctx, id, current)
if err != nil {
c.Logger.Errorw("failed to update campaign by id", "error", err)
return errs.Wrap(err)
}
// re-schedule the campaign
// TODO should this all be in the schedule method
// remove all existing schedules if the campaign is not self-managed
if !incoming.IsSelfManaged() {
err = c.CampaignRecipientRepository.DeleteByCampaigID(
ctx,
id,
)
if err != nil {
c.Logger.Errorw("failed to remove recipient groups", "error", err)
return errs.Wrap(err)
}
err = c.CampaignRepository.RemoveCampaignRecipientGroups(
ctx,
id,
)
if err != nil {
c.Logger.Errorw("failed to remove campaignrecipient groups", "error", err)
return errs.Wrap(err)
}
} else {
// if self managed remove only the campaign recipients groups
err = c.CampaignRepository.RemoveCampaignRecipientGroups(
ctx,
id,
)
if err != nil {
c.Logger.Errorw("failed to remove recipient groups", "error", err)
return errs.Wrap(err)
}
}
if incoming.RecipientGroupIDs.IsSpecified() && !incoming.RecipientGroupIDs.IsNull() {
recipientGroupIDs := incoming.RecipientGroupIDs.MustGet()
err = c.CampaignRepository.AddRecipientGroups(
ctx,
id,
recipientGroupIDs,
)
}
if err != nil {
c.Logger.Errorw("failed to add recipient groups", "error", err)
return errs.Wrap(err)
}
// preserve jitter values from incoming campaign (not persisted to db)
current.JitterMin = incoming.JitterMin
current.JitterMax = incoming.JitterMax
err = c.schedule(ctx, session, current)
if err != nil {
c.Logger.Errorw("failed to re-schedule campaign", "error", err)
return errs.Wrap(err)
}
c.AuditLogAuthorized(ae)
return nil
}
// validateAllowDenyIsSameType checks if the allow and deny lists are of the same type
// allow and deny are mutually exclusive
func (c *Campaign) validateAllowDenyIsSameTypeByIDs(
ctx context.Context,
campaign *model.Campaign,
) error {
if campaign.AllowDenyIDs.IsSpecified() && !campaign.AllowDenyIDs.IsNull() {
allowDenyIDs := campaign.AllowDenyIDs.MustGet()
if len(allowDenyIDs) == 0 {
return nil
}
isAllowList := false
for i, id := range allowDenyIDs {
entry, err := c.AllowDenyRepository.GetByID(ctx, id, &repository.AllowDenyOption{})
if err != nil {
c.Logger.Errorw("failed to get allow deny by id", "error", err)
}
allowed := entry.Allowed.MustGet()
if i == 0 {
isAllowList = allowed
continue
}
if isAllowList != allowed {
return validate.WrapErrorWithField(errors.New("allow and deny list are mutually exclusive"), "allowDenyIDs")
}
}
}
return nil
}
// updateSchedulesCampaignStartAndEndDates updates the schedules for a campaign
// it uses the first and last selected weekday and along with sending times to adjust
// the start and end of the campaign
func (c *Campaign) updateSchedulesCampaignStartAndEndDates(
campaign *model.Campaign,
) error {
// get the first and last selected weekday
campaignWeekDays := campaign.ConstraintWeekDays.MustGet().AsSlice()
startAt := campaign.SendStartAt.MustGet()
endAt := campaign.SendEndAt.MustGet()
startTime := campaign.ConstraintStartTime.MustGet()
endTime := campaign.ConstraintEndTime.MustGet()
// find the first day and start time of sending
currentDate := startAt
startFound := false
startDay := time.Time{}
lastDay := time.Time{}
for currentDate.Before(endAt) || currentDate.Equal(endAt) {
if slices.Contains(campaignWeekDays, int(currentDate.Weekday())) {
if !startFound {
startDay = currentDate
startFound = true
}
lastDay = currentDate
}
currentDate = currentDate.AddDate(0, 0, 1)
}
campaign.SendStartAt.Set(startDay.Truncate(24 * time.Hour).Add(startTime.Minutes()))
campaign.SendEndAt.Set(lastDay.Truncate(24 * time.Hour).Add(endTime.Minutes()))
return nil
}
// DeleteByID deletes a campaign by id
func (c *Campaign) DeleteByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Campaign.DeleteById", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// delete all campaign-allowDeny relations to the campaign
err = c.CampaignRepository.RemoveAllowDenyListsByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to delete campaign allow deny by campaign id", "error", err)
return errs.Wrap(err)
}
// remove all related events
err = c.CampaignRepository.DeleteEventsByCampaignID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to delete campaign events by campaign id", "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",
"campaignID", id.String(),
"error", err,
)
return errs.Wrap(err)
}
err = c.CampaignRecipientRepository.DeleteByCampaigID(
ctx,
id,
)
if err != nil {
c.Logger.Errorw("failed to remove recipient groups", "error", err)
return errs.Wrap(err)
}
// delete campaign
err = c.CampaignRepository.DeleteByID(ctx, id)
if err != nil {
c.Logger.Errorw("failed to delete campaign by id", "error", err)
return errs.Wrap(err)
}
c.AuditLogAuthorized(ae)
return nil
}
// DeleteEventByID deletes a single campaign event by its ID
// only allows deletion on open campaigns (not closed)
// recalculates the notable event if the deleted event was the notable one
func (c *Campaign) DeleteEventByID(
ctx context.Context,
session *model.Session,
eventID *uuid.UUID,
) error {
ae := NewAuditEvent("Campaign.DeleteEventByID", session)
ae.Details["eventID"] = eventID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get the event to be deleted
event, err := c.CampaignRepository.GetEventByID(ctx, eventID)
if err != nil {
c.Logger.Errorw("failed to get campaign event by id", "error", err, "eventID", eventID.String())
return errs.Wrap(err)
}
ae.Details["campaignID"] = event.CampaignID.String()
if event.RecipientID != nil {
ae.Details["recipientID"] = event.RecipientID.String()
}
// get the campaign to check if it's closed
campaign, err := c.CampaignRepository.GetByID(ctx, event.CampaignID, &repository.CampaignOption{})
if err != nil {
c.Logger.Errorw("failed to get campaign", "error", err, "campaignID", event.CampaignID.String())
return errs.Wrap(err)
}
// check if campaign is closed
if campaign.ClosedAt.IsSpecified() && !campaign.ClosedAt.IsNull() {
c.Logger.Debugw("cannot delete event from closed campaign",
"campaignID", event.CampaignID.String(),
"eventID", eventID.String(),
)
return errs.ErrCampaignAlreadyClosed
}
// get the campaign recipient to recalculate notable event after deletion
var campaignRecipient *model.CampaignRecipient
if event.RecipientID != nil {
campaignRecipient, err = c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
ctx,
event.CampaignID,
event.RecipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipient",
"error", err,
"campaignID", event.CampaignID.String(),
"recipientID", event.RecipientID.String(),
)
return errs.Wrap(err)
}
}
// delete the event
err = c.CampaignRepository.DeleteEventByID(ctx, eventID)
if err != nil {
c.Logger.Errorw("failed to delete campaign event", "error", err, "eventID", eventID.String())
return errs.Wrap(err)
}
// recalculate notable event after deleting an event for this recipient
if campaignRecipient != nil {
campaignRecipientID, err := campaignRecipient.ID.Get()
if err != nil {
c.Logger.Errorw("campaign recipient has no ID, skipping notable event update",
"error", err,
"campaignID", event.CampaignID.String(),
)
} else {
mostNotableEventTypeID, err := c.CampaignRepository.GetMostNotableEventForRecipient(
ctx,
event.CampaignID,
event.RecipientID,
eventID,
)
if err != nil {
c.Logger.Errorw("failed to get most notable event for recipient",
"error", err,
"campaignID", event.CampaignID.String(),
"recipientID", event.RecipientID.String(),
)
} else {
err = c.CampaignRecipientRepository.UpdateNotableEventByID(ctx, &campaignRecipientID, mostNotableEventTypeID)
if err != nil {
c.Logger.Errorw("failed to update campaign recipient notable event",
"error", err,
"campaignRecipientID", campaignRecipientID.String(),
)
}
}
}
}
c.AuditLogAuthorized(ae)
return nil
}
// SendNextBatch sends the next batch of emails
// atm this is only audit logged on auth failures
func (c *Campaign) SendNextBatch(
ctx context.Context,
session *model.Session,
) error {
ae := NewAuditEvent("Campaign.SendNextBatch", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get next batch
campaignRecipients, err := c.CampaignRecipientRepository.GetUnsendRecipientsForSending(
ctx,
1000, // limit
&repository.CampaignRecipientOption{
WithRecipient: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get next batch", "error", err)
return errs.Wrap(err)
}
// group campaignrecipients by campaign so if it is send via SMTP we can reuse
// the connection
campaignMap := map[string][]*model.CampaignRecipient{}
for _, campaignRecipient := range campaignRecipients {
campaignID := campaignRecipient.CampaignID.MustGet().String()
campaignMap[campaignID] = append(campaignMap[campaignID], campaignRecipient)
}
// iterate each campaign and send the messages
for campaignID, campaignRecipients := range campaignMap {
err = c.sendCampaignMessages(ctx, session, campaignID, campaignRecipients)
if err != nil {
c.Logger.Errorw("failed to send campaign messages", "error", err)
continue
}
}
return errs.Wrap(err)
}
func (c *Campaign) sendCampaignMessages(
ctx context.Context,
session *model.Session,
cid string,
campaignRecipients []*model.CampaignRecipient,
) error {
campaignID := uuid.MustParse(cid)
// fetch the campaign to ensure that it is still active and to fetch details for sending
campaign, err := c.CampaignRepository.GetByID(
ctx,
&campaignID,
&repository.CampaignOption{
WithCampaignTemplate: false,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id",
"campaignID", campaignID,
"error", err,
)
return errs.Wrap(err)
}
// check if the campaign has been close while sending is being processed
if !campaign.IsActive() {
c.Logger.Debugw("campaign is not active",
"campaignID", campaign.ID.MustGet().String(),
)
return errors.New("skipping send, campaign is not active")
}
// fetch the campaign cTemplate to get the sender and message to send
templateID, err := campaign.TemplateID.Get()
if err != nil {
c.Logger.Infow("campaign has no template", "error", err)
return errs.Wrap(errors.New("skipping send, campaign has no template"))
}
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&templateID,
&repository.CampaignTemplateOption{
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithIdentifier: true,
WithBeforeLandingProxy: true,
WithLandingProxy: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign template by id", "error", err)
closeErr := c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"failed get email",
)
return errs.Wrap(errors.Join(err, closeErr))
}
// domain
domain := cTemplate.Domain
if domain == nil {
// if the domain has been removed from the campaign template used in this campaign, close the campaign
closeErr := c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"Campaign does not have a domain relation",
)
if closeErr != nil {
return errs.Wrap(errors.Join(err, closeErr))
}
c.Logger.Warnw("Running campaign does not have a domain relation - cancelling campaign",
"campaignID", campaignID.String(),
)
return nil
}
// get email details
emailID, err := cTemplate.EmailID.Get()
if err != nil {
c.Logger.Warnw("Running campaign does not have a email relation - cancelling campaign",
"campaignID", campaignID.String(),
)
// if the email relation has been removed from the campaign template used in this campagin, close the campaign
closeErr := c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"Campaign does not have a email relation",
)
if closeErr != nil {
return errs.Wrap(errors.Join(err, closeErr))
}
return nil
}
// get campaign's company context for attachment filtering
var campaignCompanyID *uuid.UUID
if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() {
companyID := campaign.CompanyID.MustGet()
campaignCompanyID = &companyID
}
email, err := c.MailService.GetByID(
ctx,
session,
&emailID,
campaignCompanyID,
)
if err != nil {
closeErr := c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"failed get email",
)
return errs.Wrap(errors.Join(err, closeErr))
}
content, err := email.Content.Get()
if err != nil {
// if mail templates fails to parse, close the campaign
closeErr := c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"failed get email content",
)
return errs.Wrap(errors.Join(err, closeErr))
}
t := template.New("email")
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
mailTmpl, err := t.Parse(content.String())
if err != nil {
// if mail templates fails to parse, close the campaign
closeErr := c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"failed to parse the template",
)
return errs.Wrap(errors.Join(err, closeErr))
}
// check if sending is API or SMTP
isSmtpCampaign := cTemplate.SMTPConfigurationID.IsSpecified() && !cTemplate.SMTPConfigurationID.IsNull()
isAPISenderCampaign := cTemplate.APISenderID.IsSpecified() && !cTemplate.APISenderID.IsNull()
// close the campaign
if !isSmtpCampaign && !isAPISenderCampaign {
c.Logger.Warnw("Running campaign does not have a SMTP or API sender relation - cancelling campaign",
"campaignID", campaignID.String(),
)
// if there is no smtp config or api sender, then one of them has been removed from the campaigns template
return c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"Campaign does not have a either an SMTP configuration or an API Sender",
)
}
if isAPISenderCampaign {
// send via API
for _, campaignRecipient := range campaignRecipients {
// update the last attempt at timestamp so we do not accidently try sending the same
// email again if a panic or error happens in a 3. party lib.
campaignRecipientID := campaignRecipient.ID.MustGet()
campaignRecipient.LastAttemptAt = nullable.NewNullableWithValue(time.Now())
err := c.CampaignRecipientRepository.UpdateByID(
ctx,
&campaignRecipientID,
campaignRecipient,
)
if err != nil {
c.Logger.Errorw("CRITICAL - failed to update last attempted at - aborting",
"error", err,
)
return errs.Wrap(
fmt.Errorf("failed to update last attempted at: %s \nThis is critical for sending, aborting...", err),
)
}
// generate custom campaign URL if first page is MITM
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, urlErr := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if urlErr != nil {
c.Logger.Errorw("failed to get campaign url for API sender", "error", urlErr)
customCampaignURL = ""
}
// send via API with custom URL (domain and template stay the same for assets)
err = c.APISenderService.SendWithCustomURL(
ctx,
session,
cTemplate,
campaignRecipient,
domain,
mailTmpl,
email,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
c.Logger.Errorw("failed to send message via. API", "error", err)
}
err = c.saveSendingResult(
ctx,
campaignRecipient,
err,
)
if err != nil {
c.Logger.Errorw("failed to save sending result", "error", err)
return errs.Wrap(err)
}
}
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_ACTIVE,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
return nil
}
if !isSmtpCampaign {
c.Logger.Error("no sender configuration found")
return errors.New("no sender configuration found")
}
// get the SMTP configuration
smtpConfigID, err := cTemplate.SMTPConfigurationID.Get()
if err != nil {
c.Logger.Infow(
"failed to get SMTP configuration from template - template no longer usable",
"smtpConfigID", smtpConfigID,
)
return errs.Wrap(err)
}
smtpConfig, err := c.SMTPConfigService.GetByID(
ctx,
session,
&smtpConfigID,
&repository.SMTPConfigurationOption{
WithHeaders: true,
},
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("smtp configuration did not load", "error", err)
return errs.Wrap(err)
}
smtpPort, err := smtpConfig.Port.Get()
if err != nil {
c.Logger.Errorw("failed to get smtp port", "error", err)
return errs.Wrap(err)
}
smtpHost, err := smtpConfig.Host.Get()
if err != nil {
c.Logger.Errorw("failed to get smtp host", "error", err)
return errs.Wrap(err)
}
smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get()
if err != nil {
c.Logger.Errorw("failed to get smtp ignore cert errors", "error", err)
return errs.Wrap(err)
}
emailOptions := []mail.Option{
mail.WithPort(smtpPort.Int()),
mail.WithTLSConfig(
&tls.Config{
ServerName: smtpHost.String(),
// #nosec
InsecureSkipVerify: smtpIgnoreCertErrors,
// MinVersion: tls.VersionTLS12,
},
),
}
// setup authentication if provided
username, err := smtpConfig.Username.Get()
if err != nil {
c.Logger.Errorw("failed to get smtp username", "error", err)
return errs.Wrap(err)
}
password, err := smtpConfig.Password.Get()
if err != nil {
c.Logger.Errorw("failed to get smtp password", "error", err)
return errs.Wrap(err)
}
if un := username.String(); len(un) > 0 {
emailOptions = append(
emailOptions,
mail.WithUsername(
un,
),
)
if pw := password.String(); len(pw) > 0 {
emailOptions = append(
emailOptions,
mail.WithPassword(
pw,
),
)
}
}
// prepare all messages
messageOptions := []mail.MsgOption{
mail.WithNoDefaultUserAgent(),
}
// create maps between recipients and messages
// and prepare all messages
messages := []*mail.Msg{}
mailToCampaignRecipient := make(map[string]*model.CampaignRecipient, len(campaignRecipients))
for _, campaignRecipient := range campaignRecipients {
// update the last attempt at timestamp so we do not accidently try sending the same
// email again if a panic or error happens in a 3. party lib.
campaignRecipientID := campaignRecipient.ID.MustGet()
campaignRecipient.LastAttemptAt = nullable.NewNullableWithValue(time.Now())
err := c.CampaignRecipientRepository.UpdateByID(
ctx,
&campaignRecipientID,
campaignRecipient,
)
if err != nil {
c.Logger.Errorw("CRITICAL - failed to update last attempted at - aborting",
"error", err,
)
return fmt.Errorf("failed to update last attempted at: %s \nThis is critical for sending, aborting...", err)
}
m := mail.NewMsg(messageOptions...)
/* TODO at the moment the mail envelope from is a email, so it can not be empty by definition
if envelopefrom.string() == "" {
// extract the email only from mail.mailheaderfrom
// and use that as the envelope from
// this is a fallback if the envelope from is not set
address, err := netmail.parseaddress(email.mailheaderfrom)
if err != nil {
c.logger.errorw("failed to parse mail header 'from'", "error", err)
return false,errs.Wrap(err)
}
err = m.envelopefrom(address.address)
if err != nil {
c.logger.errorw("failed to set envelope from", "error", err)
return false,errs.Wrap(err)
}
} else {
err = m.envelopefrom(email.mailenvelopefrom)
if err != nil {
c.logger.errorw("failed to set envelope from", "error", err)
return false,errs.Wrap(err)
}
}
*/
err = m.EnvelopeFrom(email.MailEnvelopeFrom.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to set envelope from", "error", err)
return errs.Wrap(err)
}
// headers
err = m.From(email.MailHeaderFrom.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to set mail header 'From'", "error", err)
return errs.Wrap(err)
}
// handle a race where the recipient has been removed/anonymized
if campaignRecipient.Recipient == nil {
crid := campaignRecipient.ID.MustGet()
err := c.CampaignRecipientRepository.Cancel(
ctx,
[]*uuid.UUID{&crid},
)
if err != nil {
return errors.New("Missing recipient from campaign recipient")
}
c.Logger.Info("A campaign recipient had no recipient - cancelled - this can happend in rare race conditions or curruption bugs")
continue
}
recpEmail := campaignRecipient.Recipient.Email.MustGet().String()
err = m.To(recpEmail)
if err != nil {
c.Logger.Errorw("failed to set mail header 'To'", "error", err)
return errs.Wrap(err)
}
// store a map between recipient email and message
// so we can later save the sending result
mailToCampaignRecipient[m.GetToString()[0]] = campaignRecipient
// custom headers
if headers := smtpConfig.Headers; headers != nil {
for _, header := range headers {
key := header.Key.MustGet()
value := header.Value.MustGet()
m.SetGenHeader(
mail.Header(key.String()),
value.String(),
)
}
}
urlIdentifier := cTemplate.URLIdentifier
if urlIdentifier == nil {
c.Logger.Error("url identifier is MUST be loaded for the campaign template")
return fmt.Errorf("url identifier is MUST be loaded for the campaign template")
}
// get template domain for assets and tracking pixel
domainName, err := domain.Name.Get()
if err != nil {
c.Logger.Errorw("failed to get domain name", "error", err)
return errs.Wrap(err)
}
urlPath := cTemplate.URLPath.MustGet().String()
// generate custom campaign URL if first page is MITM
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url", "error", err)
return errs.Wrap(err)
}
t := c.TemplateService.CreateMail(
ctx,
domainName.String(),
urlIdentifier.Name.MustGet(),
urlPath,
campaignRecipient,
email,
nil,
campaignCompanyID,
)
// override campaign URL if it's different from template domain URL
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName.String(), urlPath, urlIdentifier.Name.MustGet(), recipientID.String())
if customCampaignURL != templateURL {
(*t)["URL"] = customCampaignURL
}
// process subject through template
subjectTemplate, err := template.New("subject").Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)).Parse(email.MailHeaderSubject.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to parse subject template", "error", err)
return errs.Wrap(err)
}
var subjectBuffer bytes.Buffer
err = subjectTemplate.Execute(&subjectBuffer, t)
if err != nil {
c.Logger.Errorw("failed to execute subject template", "error", err)
return errs.Wrap(err)
}
m.Subject(subjectBuffer.String())
var bodyBuffer bytes.Buffer
err = mailTmpl.Execute(&bodyBuffer, t)
if err != nil {
c.Logger.Errorw("failed to execute mail template", "error", err)
return errs.Wrap(err)
}
m.SetBodyString("text/html", bodyBuffer.String())
// attachments
attachments := email.Attachments
for _, emailAttachment := range attachments {
attachment := emailAttachment.Attachment
p, err := c.MailService.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
// check if attachment should be inline (for cid: references)
if emailAttachment.IsInline {
// inline attachment - embedded in email body, can be referenced via cid:filename
if !attachment.EmbeddedContent.MustGet() {
// simple inline image - no template processing needed
m.EmbedFile(p.String())
} else {
// inline image with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
UpdatedAt: email.UpdatedAt,
Name: email.Name,
MailEnvelopeFrom: email.MailEnvelopeFrom,
MailHeaderFrom: email.MailHeaderFrom,
MailHeaderSubject: email.MailHeaderSubject,
Content: email.Content,
AddTrackingPixel: email.AddTrackingPixel,
CompanyID: email.CompanyID,
Attachments: email.Attachments,
Company: email.Company,
}
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
&attachmentAsEmail,
nil,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
}
// use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
// the filename becomes the Content-ID, so use <img src="cid:filename.jpg"> in HTML
m.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
} else if !attachment.EmbeddedContent.MustGet() {
// regular attachment - shows in attachment list
m.AttachFile(p.String())
} else {
// regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
UpdatedAt: email.UpdatedAt,
Name: email.Name,
MailEnvelopeFrom: email.MailEnvelopeFrom,
MailHeaderFrom: email.MailHeaderFrom,
MailHeaderSubject: email.MailHeaderSubject,
Content: email.Content,
AddTrackingPixel: email.AddTrackingPixel,
CompanyID: email.CompanyID,
Attachments: email.Attachments,
Company: email.Company,
}
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
&attachmentAsEmail,
nil,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
}
m.AttachReadSeeker(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
}
messages = append(messages, m)
}
// send the messages
// the client sends all the messages and ensure that all messages are sent
// in the same connection
var mc *mail.Client
// Try different authentication methods based on configuration
// If username is provided, use authentication; otherwise try without auth first
if un := username.String(); len(un) > 0 {
// Try CRAM-MD5 first when credentials are provided
emailOptionsCRAM5 := append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsCRAM5...)
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, messages...)
// Check if it's an authentication error and try PLAIN auth
if err != nil && (strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "534 ") ||
strings.Contains(err.Error(), "538 ") ||
strings.Contains(err.Error(), "CRAM-MD5") ||
strings.Contains(err.Error(), "authentication failed")) {
c.Logger.Debugf("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err)
emailOptionsBasic := emailOptions
emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...)
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, messages...)
}
} else {
// No credentials provided, try without authentication (e.g., local postfix)
mc, _ = mail.NewClient(smtpHost.String(), emailOptions...)
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, messages...)
// If no-auth fails and we get an auth-related error, log it appropriately
if err != nil && (strings.Contains(err.Error(), "530 ") ||
strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "authentication required") ||
strings.Contains(err.Error(), "AUTH")) {
c.Logger.Warnw("Server requires authentication but no credentials provided", "error", err)
}
}
if err != nil {
c.Logger.Errorw("failed to send test email", "error", err)
}
// check each message if has been sent and save the result for each
for _, m := range messages {
var sendError error = nil
if m.HasSendError() {
sendError = m.SendError()
}
// deref 0 as only a single recipient in each mail
to := m.GetToString()[0]
campaignRecipient := mailToCampaignRecipient[to]
err := c.saveSendingResult(
ctx,
campaignRecipient,
sendError,
)
if err != nil {
c.Logger.Errorw("failed to save sending result", "error", err)
}
}
// check if most notable event
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_ACTIVE,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
return nil
}
// saveSendingResult saves a result from a send campaign atttempts
func (c *Campaign) saveSendingResult(
ctx context.Context,
campaignRecipient *model.CampaignRecipient,
sendError error,
) error {
if sendError == nil {
campaignRecipient.SentAt = nullable.NewNullableWithValue(time.Now())
}
campaignRecipientID := campaignRecipient.ID.MustGet()
err := c.CampaignRecipientRepository.UpdateByID(
ctx,
&campaignRecipientID,
campaignRecipient,
)
if err != nil {
c.Logger.Errorw("failed to update campaign recipient by id", "error", err)
}
// persist the event
id := uuid.New()
eventName := data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT
if sendError != nil {
eventName = data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED
}
eventID := cache.EventIDByName[eventName]
data := vo.NewEmptyOptionalString1MB()
if sendError != nil {
data, err = vo.NewOptionalString1MB(sendError.Error())
if err != nil {
return errs.Wrap(fmt.Errorf("failed to create reason: %s", err))
}
}
campaignID := campaignRecipient.CampaignID.MustGet()
recipientID := campaignRecipient.RecipientID.MustGet()
campaign, err := c.CampaignRepository.GetByID(
ctx,
&campaignID,
&repository.CampaignOption{},
)
if err != nil {
return errs.Wrap(err)
}
var campaignEvent *model.CampaignEvent
if !campaign.IsAnonymous.MustGet() {
campaignEvent = &model.CampaignEvent{
ID: &id,
CampaignID: &campaignID,
RecipientID: &recipientID,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
EventID: eventID,
Data: data,
Metadata: vo.NewEmptyOptionalString1MB(),
}
} else {
campaignEvent = &model.CampaignEvent{
ID: &id,
CampaignID: &campaignID,
RecipientID: nil,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
EventID: eventID,
Data: data,
Metadata: vo.NewEmptyOptionalString1MB(),
}
}
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
return fmt.Errorf("failed to save event: %s", err)
}
// handle most notable event
err = c.SetNotableCampaignRecipientEvent(
ctx,
campaignRecipient,
cache.EventNameByID[eventID.String()],
)
if err != nil {
// logging was done in the previous call
return errs.Wrap(err)
}
// handle webhook
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
return errs.Wrap(err)
}
if webhookID == nil {
return nil
}
err = c.HandleWebhook(
ctx,
webhookID,
&campaignID,
&recipientID,
eventName,
nil,
)
if err != nil {
return errs.Wrap(err)
}
return nil
}
// saveEventCampaignClose saves an event about closing a campaign
func (c *Campaign) saveEventCampaignClose(
ctx context.Context,
campaignID *uuid.UUID,
reason string,
) error {
// persist the event
id := uuid.New()
r, err := vo.NewOptionalString1MB(reason)
if err != nil {
return errs.Wrap(err)
}
campaignEvent := &model.CampaignEvent{
ID: &id,
CampaignID: campaignID,
RecipientID: nil,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
EventID: cache.EventIDByName[data.EVENT_CAMPAIGN_CLOSED],
Data: r,
Metadata: vo.NewEmptyOptionalString1MB(),
}
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
return fmt.Errorf("failed to save event: %s", err)
}
// handle webhook
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, campaignID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
return errs.Wrap(err)
}
if webhookID == nil {
return nil
}
err = c.HandleWebhook(
ctx,
webhookID,
campaignID,
nil,
data.EVENT_CAMPAIGN_CLOSED,
nil,
)
if err != nil {
return errs.Wrap(err)
}
return nil
}
// HandleCloseCampaigns closes campaigns that are past their end time
func (c *Campaign) HandleCloseCampaigns(
ctx context.Context,
session *model.Session,
) error {
ae := NewAuditEvent("Campaign.HandleCloseCampaigns", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get all campaigns that are past their end time
// and not yet closed
campaigns, err := c.CampaignRepository.GetAllReadyToClose(
ctx,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get closed campaigns", "error", err)
return errs.Wrap(err)
}
// close the campaigns
closedCampaignIDs := []string{}
for _, campaign := range campaigns.Rows {
campaignID := campaign.ID.MustGet()
closedCampaignIDs = append(closedCampaignIDs, campaignID.String())
c.Logger.Debugw("closing campaign with id", "campaignID", campaignID)
var err error
// if there is no campaign template closing is due to missing template
campaignTemplateID, err := campaign.TemplateID.Get()
if err != nil {
err = c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"Campaign closed due to missing campaign template",
)
c.handleCloseError(err, &campaignID)
return errs.Wrap(err)
}
// check if the template is unusable
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&campaignTemplateID,
&repository.CampaignTemplateOption{},
)
if cTemplate == nil || err != nil {
err = c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"Campaign closed due to unusable template",
)
c.handleCloseError(err, &campaignID)
return errs.Wrap(err)
}
err = c.closeCampaign(
ctx,
session,
&campaignID,
campaign,
"Campaign closed due to over close time",
)
}
if len(closedCampaignIDs) > 0 {
ae.Details["closedCampaignIds"] = closedCampaignIDs
c.AuditLogAuthorized(ae)
}
return nil
}
func (c *Campaign) handleCloseError(err error, campaignID *uuid.UUID) {
if err != nil && !errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Logger.Errorw("failed to close campaign by id", "error", err)
return
}
if go_errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Logger.Debugw("campaign already closed", "error", err)
return
}
c.Logger.Debugw("closed campaign with id", "campaignID", campaignID)
}
// HandleAnonymizeCampaigns anonymizes campaigns are ready for anonymization
func (c *Campaign) HandleAnonymizeCampaigns(
ctx context.Context,
session *model.Session,
) error {
ae := NewAuditEvent("Campaign.HandleAnonymizeCampaigns", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get all campaigns that are past their end time
// and not yet closed
campaigns, err := c.CampaignRepository.GetReadyToAnonymize(
ctx,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get ready to anonymize campaigns", "error", err)
return errs.Wrap(err)
}
// close and anonymize the campaigns
affectedIds := []string{}
for _, campaign := range campaigns.Rows {
campaignID := campaign.ID.MustGet()
affectedIds = append(affectedIds, campaignID.String())
c.Logger.Debugw("anonymizing campaign with id", "campaignID", campaignID)
err = c.AnonymizeByID(ctx, session, &campaignID)
if err != nil && !errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Logger.Errorw("failed to anonymize campaign by id", "error", err)
continue
}
if errors.Is(err, errs.ErrCampaignAlreadyAnonymized) {
c.Logger.Debugw("campaign already anonymized", "error", err)
continue
}
c.Logger.Debugw("anonymized campaign with id", "campaignID", campaignID)
}
if len(affectedIds) > 0 {
ae.Details["anonymizedCampaignIds"] = affectedIds
c.AuditLogAuthorized(ae)
}
return nil
}
// CloseCampaignByID closes a campaign by id
func (c *Campaign) CloseCampaignByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Campaign.CloseCampaignByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.Wrap(errs.ErrAuthorizationFailed)
}
// get the campaign
campaign, err := c.CampaignRepository.GetByID(
ctx,
id,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id: %s", err)
return errs.Wrap(err)
}
err = c.closeCampaign(ctx, session, id, campaign, "Manually closed")
if err != nil {
c.Logger.Errorw("failed to close campaign by id", "error", err)
return errs.Wrap(err)
}
c.AuditLogAuthorized(ae)
return nil
}
// closeCampaign closes a campaign
func (c *Campaign) closeCampaign(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
campaign *model.Campaign,
reason string,
) error {
if campaign == nil {
return errs.NewCustomError(errors.New("campaign is nil"))
}
c.Logger.Debugw("closing campaign with id", "id", id.String())
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
return errs.ErrAuthorizationFailed
}
// find all recipients that are not sent and cancel them for this campaign
campaignRecipients, err := c.CampaignRecipientRepository.GetUnsendRecipients(
ctx,
id,
repository.NO_LIMIT,
&repository.CampaignRecipientOption{},
)
c.Logger.Debugw("found unsent recipients to cancel for campaign", "count", len(campaignRecipients), "campaignID", id.String())
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to get unsent recipients", "error", err)
return errs.Wrap(err)
}
campaignRecipientUUIDs := []*uuid.UUID{}
for _, cr := range campaignRecipients {
campaignRecipientID := cr.ID.MustGet()
campaignRecipientUUIDs = append(campaignRecipientUUIDs, &campaignRecipientID)
}
err = c.CampaignRecipientRepository.Cancel(ctx, campaignRecipientUUIDs)
if err != nil {
c.Logger.Errorw("failed to cancel recipients", "error", err)
return errs.Wrap(err)
}
err = campaign.Closed()
if go_errors.Is(err, errs.ErrCampaignAlreadyClosed) {
c.Logger.Debugw("campaign already closed", "error", err)
return errs.Wrap(err)
}
if err != nil {
c.Logger.Errorw("failed to close campaign by id", "error", err)
return errs.Wrap(err)
}
err = c.CampaignRepository.UpdateByID(ctx, id, campaign)
if err != nil {
c.Logger.Errorw("failed to close campaign by id", "error", err)
return errs.Wrap(err)
}
err = c.saveEventCampaignClose(
ctx,
id,
reason,
)
if err != nil {
c.Logger.Errorw("failed to save event about closing campaign", "error", err)
}
err = c.setMostNotableCampaignEvent(
ctx,
campaign,
data.EVENT_CAMPAIGN_CLOSED,
)
if err != nil {
// err is logged in method call
return errs.Wrap(err)
}
// Generate campaign statistics when closing (skip test campaigns)
if !campaign.IsTest.MustGet() {
c.Logger.Debugf("generating campaign statistics", "campaignID", id.String())
err = c.GenerateCampaignStats(ctx, session, id)
if err != nil {
c.Logger.Errorw("failed to generate campaign statistics", "error", err, "campaignID", id.String())
// Don't fail the close operation if stats generation fails
} else {
c.Logger.Debugf("successfully generated campaign statistics", "campaignID", id.String())
}
} else {
c.Logger.Debugf("skipping stats generation for test campaign", "campaignID", id.String())
}
return nil
}
// GetCampaignEmailBody returns the rendered email for a self managed campaign recipient
func (c *Campaign) GetCampaignEmailBody(
ctx context.Context,
session *model.Session,
campaignRecipientID *uuid.UUID,
) (string, error) {
ae := NewAuditEvent("Campaign.GetCampaignEmailBody", session)
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return "", errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return "", errs.ErrAuthorizationFailed
}
// check recipient is in a active campaign
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
ctx,
campaignRecipientID,
&repository.CampaignRecipientOption{
WithRecipient: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return "", errs.Wrap(err)
}
if campaignRecipient.RecipientID.IsNull() {
return "", errs.NewCustomError(
errors.New("recipient is anonymized"),
)
}
campaignID := campaignRecipient.CampaignID.MustGet()
campaign, err := c.CampaignRepository.GetByID(
ctx,
&campaignID,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return "", errs.Wrap(err)
}
templateID, err := campaign.TemplateID.Get()
if err != nil {
c.Logger.Errorw("failed to get template from campaign, has no template", "error", err)
return "", errs.Wrap(err)
}
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&templateID,
&repository.CampaignTemplateOption{
WithIdentifier: true,
WithBeforeLandingProxy: true,
WithLandingProxy: true,
},
)
emailID, err := cTemplate.EmailID.Get()
if err != nil {
c.Logger.Infow("failed email from template - template ID", "templateID", templateID)
return "", errs.NewValidationError(
errors.New("Campaign template has no email"),
)
}
// get the email
// get campaign's company context for attachment filtering
var campaignCompanyID *uuid.UUID
if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() {
companyID := campaign.CompanyID.MustGet()
campaignCompanyID = &companyID
}
email, err := c.MailService.GetByID(
ctx,
session,
&emailID,
campaignCompanyID,
)
if err != nil {
c.Logger.Errorw("failed to get message by id", "error", err)
return "", errs.Wrap(err)
}
urlIdentifier := cTemplate.URLIdentifier
if urlIdentifier == nil {
return "", errors.New("url identifier is nil")
}
// get template domain for assets and tracking pixel
domainID, err := cTemplate.DomainID.Get()
if err != nil {
c.Logger.Infow("failed domain from template - template ID", "templateID", templateID)
return "", errs.NewValidationError(
errors.New("Campaign template has no domain"),
)
}
domain, err := c.DomainService.GetByID(
ctx,
session,
&domainID,
&repository.DomainOption{},
)
if err != nil {
c.Logger.Errorw("failed to get domain by id", "error", err)
return "", errs.Wrap(err)
}
urlPath := cTemplate.URLPath.MustGet().String()
// generate custom campaign URL if first page is MITM
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, campaignRecipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url", "error", err)
return "", errs.Wrap(err)
}
// no audit on read
return c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
email,
nil, // no api sender for preview
customCampaignURL,
campaignCompanyID,
)
}
// GetLandingPageURLByCampaignRecipientID generates the lure URL for a campaign recipient.
// if the first page in the campaign flow is a mitm proxy, the url goes directly to the
// mitm domain instead of redirecting through the template domain.
func (c *Campaign) GetLandingPageURLByCampaignRecipientID(
ctx context.Context,
session *model.Session,
campaignRecipientID *uuid.UUID,
) (string, error) {
ae := NewAuditEvent("Campaign.GetLandingPageURLByCampaignRecipientID", session)
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return "", errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return "", errs.ErrAuthorizationFailed
}
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
ctx,
campaignRecipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return "", errs.Wrap(err)
}
campaignID := campaignRecipient.CampaignID.MustGet()
campaign, err := c.CampaignRepository.GetByID(
ctx,
&campaignID,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return "", errs.Wrap(err)
}
templateID, err := campaign.TemplateID.Get()
if err != nil {
c.Logger.Errorw("failed to get campaign template, campaign has no template", "error", err)
return "", errs.Wrap(err)
}
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&templateID,
&repository.CampaignTemplateOption{
WithIdentifier: true,
WithBeforeLandingProxy: true,
WithLandingProxy: true,
},
)
// determine if we should use mitm domain for first page
var baseURL string
var urlPath string
idIdentifier := cTemplate.URLIdentifier.Name.MustGet()
// check if first page is a mitm proxy
firstPageProxy := c.getFirstPageProxy(cTemplate)
if firstPageProxy != nil {
// get the phishing domain for this proxy
phishingDomain, err := c.getPhishingDomainForProxy(ctx, firstPageProxy)
if err != nil {
c.Logger.Errorw("failed to get phishing domain for first page proxy", "error", err)
// fallback to template domain
firstPageProxy = nil
} else {
// use phishing domain directly
startURL, err := firstPageProxy.StartURL.Get()
if err != nil {
c.Logger.Errorw("failed to get start url from first page proxy", "error", err)
return "", errs.Wrap(err)
}
parsedStartURL, err := url.Parse(startURL.String())
if err != nil {
c.Logger.Errorw("failed to parse start url from first page proxy", "error", err)
return "", errs.Wrap(err)
}
baseURL = "https://" + phishingDomain
// use campaign template URLPath if available, otherwise fall back to proxy StartURL path
if templateURLPath, err := cTemplate.URLPath.Get(); err == nil && templateURLPath.String() != "" {
urlPath = templateURLPath.String()
} else {
urlPath = parsedStartURL.Path
}
}
}
if firstPageProxy == nil {
// use template domain (current behavior)
domainID, err := cTemplate.DomainID.Get()
if err != nil {
c.Logger.Infow("failed email from template - template ID", "templateID", templateID)
return "", errs.NewValidationError(
errors.New("Campaign template has no email"),
)
}
domain, err := c.DomainService.GetByID(
ctx,
session,
&domainID,
&repository.DomainOption{},
)
if err != nil {
c.Logger.Errorw("failed to get domain by id", err)
return "", errs.Wrap(err)
}
urlPath = cTemplate.URLPath.MustGet().String()
baseURL = "https://" + domain.Name.MustGet().String()
}
// build final url
separator := "?"
if strings.Contains(baseURL, "?") {
separator = "&"
}
url := fmt.Sprintf("%s%s%s%s=%s", baseURL, urlPath, separator, idIdentifier, campaignRecipientID.String())
// no audit on read
return url, nil
}
// getFirstPageProxy returns the proxy for the first page in the campaign flow
// returns nil if the first page is not a proxy
func (c *Campaign) getFirstPageProxy(template *model.CampaignTemplate) *model.Proxy {
if template.BeforeLandingPageID.IsNull() != true {
return nil
}
if template.BeforeLandingProxyID.IsNull() != true {
return template.BeforeLandingProxy
}
if template.LandingPageID.IsNull() != true {
return nil
}
if template.LandingProxyID.IsNull() != true {
return template.LandingProxy
}
if template.AfterLandingPageID.IsNull() != true {
return nil
}
if template.AfterLandingProxyID.IsNull() != true {
return template.AfterLandingProxy
}
return nil
}
// getPhishingDomainForProxy finds the phishing domain that maps to the proxy's start url
func (c *Campaign) getPhishingDomainForProxy(ctx context.Context, proxy *model.Proxy) (string, error) {
startURL, err := proxy.StartURL.Get()
if err != nil {
return "", fmt.Errorf("failed to get start url from proxy: %w", err)
}
proxyConfig, err := proxy.ProxyConfig.Get()
if err != nil {
return "", fmt.Errorf("failed to get proxy config: %w", err)
}
// parse the proxy configuration to find domain mappings
var rawConfig map[string]interface{}
err = yaml.Unmarshal([]byte(proxyConfig.String()), &rawConfig)
if err != nil {
return "", fmt.Errorf("failed to parse proxy config yaml: %w", err)
}
// parse the start URL to get the target domain
parsedStartURL, err := url.Parse(startURL.String())
if err != nil {
return "", fmt.Errorf("failed to parse start url: %w", err)
}
startDomain := parsedStartURL.Host
// find the phishing domain mapping for the start URL domain
for originalHost, domainData := range rawConfig {
if originalHost == "proxy" || originalHost == "global" {
continue
}
if originalHost == startDomain {
if domainMap, ok := domainData.(map[string]interface{}); ok {
if to, exists := domainMap["to"]; exists {
if toStr, ok := to.(string); ok {
return toStr, nil
}
}
}
}
}
return "", fmt.Errorf("no phishing domain mapping found for start url domain: %s", startDomain)
}
// SetSentAtByCampaignRecipientID sets the sent at time for a recipient
func (c *Campaign) SetSentAtByCampaignRecipientID(
ctx context.Context,
session *model.Session,
campaignRecipientID *uuid.UUID,
) error {
ae := NewAuditEvent("Campaign.SetSentAtByCampaignRecipientID", session)
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get campaignRecipient
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
ctx,
campaignRecipientID,
&repository.CampaignRecipientOption{
WithCampaign: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return errs.Wrap(err)
}
campaign := campaignRecipient.Campaign
// check if the campaign recipient is in a active campaign
if !campaign.IsActive() {
c.Logger.Debugw("failed to cancel campaign recipient by id: campaign is inactive",
"campaignID", campaign.ID.MustGet().String(),
)
return errors.New("campaign is inactive")
}
if !campaignRecipient.CancelledAt.IsNull() &&
campaignRecipient.CancelledAt.MustGet().Before(time.Now()) {
c.Logger.Debugw("failed to cancel campaign recipient by id: already cancelled",
"campaignrecipientID", campaignRecipientID.String(),
)
return errors.New("campaign recipient already cancelled")
}
campaignRecipient.SentAt = nullable.NewNullableWithValue(time.Now())
err = c.CampaignRecipientRepository.UpdateByID(
ctx,
campaignRecipientID,
campaignRecipient,
)
if err != nil {
c.Logger.Errorw("wailed to cancel campaign recipient by recipient id", "error", err)
return errs.Wrap(err)
}
// create an event for the sent email
id := uuid.New()
campaignID := campaignRecipient.CampaignID.MustGet()
recipientID := campaignRecipient.RecipientID.MustGet()
var campaignEvent *model.CampaignEvent
if campaign.IsAnonymous.MustGet() {
campaignEvent = &model.CampaignEvent{
ID: &id,
CampaignID: &campaignID,
RecipientID: nil,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
EventID: cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT],
Data: vo.NewEmptyOptionalString1MB(),
Metadata: vo.NewEmptyOptionalString1MB(),
}
} else {
campaignEvent = &model.CampaignEvent{
ID: &id,
CampaignID: &campaignID,
RecipientID: &recipientID,
IP: vo.NewOptionalString64Must(""),
UserAgent: vo.NewOptionalString255Must(""),
EventID: cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT],
Data: vo.NewEmptyOptionalString1MB(),
Metadata: vo.NewEmptyOptionalString1MB(),
}
}
err = c.CampaignRepository.SaveEvent(ctx, campaignEvent)
if err != nil {
return errs.Wrap(err)
}
c.AuditLogAuthorized(ae)
// handle webhook
webhookID, err := c.CampaignRepository.GetWebhookIDByCampaignID(ctx, &campaignID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.Logger.Errorw("failed to get webhook id by campaign id", "error", err)
return errs.Wrap(err)
}
if errors.Is(err, gorm.ErrRecordNotFound) || webhookID == nil {
return nil
}
err = c.HandleWebhook(
ctx,
webhookID,
&campaignID,
&recipientID,
data.EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT,
nil,
)
if err != nil {
return errs.Wrap(err)
}
return nil
}
// HandleWebhook handles a webhook
// it must only be called from secure contexts as it is not checked for permissions
func (c *Campaign) HandleWebhook(
ctx context.Context,
webhookID *uuid.UUID,
campaignID *uuid.UUID,
recipientID *uuid.UUID,
eventName string,
capturedData map[string]interface{},
) error {
campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID)
if err != nil {
return errs.Wrap(err)
}
email, err := c.RecipientRepository.GetEmailByID(ctx, recipientID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errs.Wrap(err)
}
webhook, err := c.WebhookRepository.GetByID(ctx, webhookID)
if err != nil {
return errs.Wrap(err)
}
// check campaign webhook data level
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
if err != nil {
return errs.Wrap(err)
}
// check if this event should trigger webhook notification
webhookEvents := 0
if events, err := campaign.WebhookEvents.Get(); err == nil {
webhookEvents = events
}
// 0 means all events (backward compatibility)
// otherwise check if the event bit is set
if !model.IsWebhookEventEnabled(webhookEvents, eventName) {
// event not in selected events, skip webhook
return nil
}
// determine what data to send based on webhookIncludeData level
dataLevel := model.WebhookDataLevelFull
if level, err := campaign.WebhookIncludeData.Get(); err == nil {
dataLevel = level
}
now := time.Now()
webhookReq := WebhookRequest{
Time: &now,
Event: eventName,
}
// apply data level filters
switch dataLevel {
case model.WebhookDataLevelNone:
// only time and event - no campaign name, no email, no data
case model.WebhookDataLevelBasic:
// include campaign name but no email or captured data
webhookReq.CampaignName = campaignName
case model.WebhookDataLevelFull:
// include everything
webhookReq.CampaignName = campaignName
webhookReq.Data = capturedData
if email != nil {
webhookReq.Email = email.String()
}
}
// the webhook is handles as a different go routine
// so we don't block the campaign handling thread
go func() {
c.Logger.Debugw("sending webhook", "url", webhook.URL.MustGet().String())
_, err := c.WebhookService.Send(ctx, webhook, &webhookReq)
if err != nil {
c.Logger.Errorw("failed to send webhook", "error", err)
}
c.Logger.Debugw("sending webhook completed", "url", webhook.URL.MustGet().String())
}()
return nil
}
// AnonymizeByID anonymizes a campaign including the events
func (c *Campaign) AnonymizeByID(
ctx context.Context,
session *model.Session,
id *uuid.UUID,
) error {
ae := NewAuditEvent("Campaign.AnonymizeByID", session)
ae.Details["id"] = id.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
return errs.ErrAuthorizationFailed
}
// get campaign to check it exists and etc
campaign, err := c.CampaignRepository.GetByID(
ctx,
id,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return errs.Wrap(err)
}
// check if campaign is active, cause then it should be closed before continueing
if campaign.IsActive() {
err = c.closeCampaign(
ctx,
session,
id,
campaign,
"campaign is not active",
)
}
if err != nil {
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)
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
}
// SendEmailByCampaignRecipientID sends an email to a specific campaign recipient
// Multiple sends to the same recipient are allowed to support retry scenarios and follow-ups.
func (c *Campaign) SendEmailByCampaignRecipientID(
ctx context.Context,
session *model.Session,
campaignRecipientID *uuid.UUID,
) error {
ae := NewAuditEvent("Campaign.SendEmailByCampaignRecipientID", session)
ae.Details["campaignRecipientId"] = campaignRecipientID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get campaign recipient
campaignRecipient, err := c.CampaignRecipientRepository.GetByID(
ctx,
campaignRecipientID,
&repository.CampaignRecipientOption{
WithRecipient: true,
WithCampaign: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign recipient by id", "error", err)
return errs.Wrap(err)
}
campaign := campaignRecipient.Campaign
if campaign == nil {
return errors.New("campaign recipient has no campaign loaded")
}
// check if campaign is active
if !campaign.IsActive() {
return errors.New("campaign is not active")
}
// check if recipient exists (not anonymized)
if campaignRecipient.Recipient == nil {
return errors.New("recipient is anonymized or deleted")
}
// check if cancelled
if !campaignRecipient.CancelledAt.IsNull() {
return errors.New("recipient has been cancelled")
}
campaignID := campaign.ID.MustGet()
// add resend information to audit log
isResend := !campaignRecipient.SentAt.IsNull()
ae.Details["isResend"] = isResend
if isResend {
ae.Details["previouslySentAt"] = campaignRecipient.SentAt.MustGet().Format(time.RFC3339)
}
// send the email using existing logic from sendCampaignMessages
err = c.sendSingleCampaignMessage(ctx, session, &campaignID, campaignRecipient)
if err != nil {
// the failure is already logged in the campaign event with reason
c.Logger.Warnw("failed to send campaign message", "reason", err)
// don't wrap error, return as-is so controller can handle it as bad request
return errs.Wrap(err)
}
c.AuditLogAuthorized(ae)
return nil
}
// sendSingleCampaignMessage sends an email to a single campaign recipient
func (c *Campaign) sendSingleCampaignMessage(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
campaignRecipient *model.CampaignRecipient,
) error {
// get campaign template details - similar logic from sendCampaignMessages
campaign, err := c.CampaignRepository.GetByID(
ctx,
campaignID,
&repository.CampaignOption{},
)
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return errs.Wrap(err)
}
templateID, err := campaign.TemplateID.Get()
if err != nil {
return errors.New("campaign has no template")
}
cTemplate, err := c.CampaignTemplateService.GetByID(
ctx,
session,
&templateID,
&repository.CampaignTemplateOption{
WithDomain: true,
WithSMTPConfiguration: true,
WithAPISender: true,
WithIdentifier: true,
WithBeforeLandingProxy: true,
WithLandingProxy: true,
},
)
if err != nil {
c.Logger.Errorw("failed to get campaign template by id", "error", err)
return errs.Wrap(err)
}
// check domain
domain := cTemplate.Domain
if domain == nil {
return errors.New("campaign template has no domain")
}
// get email details
emailID, err := cTemplate.EmailID.Get()
if err != nil {
return errors.New("campaign template has no email")
}
// get campaign's company context for attachment filtering
var campaignCompanyID *uuid.UUID
if campaign.CompanyID.IsSpecified() && !campaign.CompanyID.IsNull() {
companyID := campaign.CompanyID.MustGet()
campaignCompanyID = &companyID
}
email, err := c.MailService.GetByID(ctx, session, &emailID, campaignCompanyID)
if err != nil {
c.Logger.Errorw("failed to get email by id", "error", err)
return errs.Wrap(err)
}
// update last attempt timestamp
campaignRecipientID := campaignRecipient.ID.MustGet()
campaignRecipient.LastAttemptAt = nullable.NewNullableWithValue(time.Now())
err = c.CampaignRecipientRepository.UpdateByID(ctx, &campaignRecipientID, campaignRecipient)
if err != nil {
c.Logger.Errorw("failed to update last attempted at", "error", err)
return errs.Wrap(err)
}
// prepare template for rendering
content, err := email.Content.Get()
if err != nil {
return errors.New("failed to get email content")
}
t := template.New("email")
t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID))
mailTmpl, err := t.Parse(content.String())
if err != nil {
return errs.Wrap(err)
}
// check sending method
isSmtpCampaign := cTemplate.SMTPConfigurationID.IsSpecified() && !cTemplate.SMTPConfigurationID.IsNull()
isAPISenderCampaign := cTemplate.APISenderID.IsSpecified() && !cTemplate.APISenderID.IsNull()
if !isSmtpCampaign && !isAPISenderCampaign {
return errors.New("campaign template has no SMTP configuration or API sender")
}
if isAPISenderCampaign {
// generate custom campaign URL if first page is MITM
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, urlErr := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if urlErr != nil {
c.Logger.Errorw("failed to get campaign url for API sender", "error", urlErr)
customCampaignURL = ""
}
// send via API with custom URL (domain and template stay the same for assets)
err = c.APISenderService.SendWithCustomURL(
ctx,
session,
cTemplate,
campaignRecipient,
domain,
mailTmpl,
email,
customCampaignURL,
campaignCompanyID,
)
} else {
// send via SMTP
err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, campaignCompanyID)
}
// save sending result
saveErr := c.saveSendingResult(ctx, campaignRecipient, err)
if saveErr != nil {
c.Logger.Errorw("failed to save sending result", "error", saveErr)
return errs.Wrap(saveErr)
}
// if there was a sending error, log it as info since it's expected (e.g., invalid recipient)
if err != nil {
c.Logger.Infow("campaign message delivery failed", "reason", err.Error())
}
return err
}
// sendSingleEmailSMTP sends an email to a single recipient via SMTP
func (c *Campaign) sendSingleEmailSMTP(
ctx context.Context,
session *model.Session,
cTemplate *model.CampaignTemplate,
campaignRecipient *model.CampaignRecipient,
domain *model.Domain,
mailTmpl *template.Template,
email *model.Email,
campaignCompanyID *uuid.UUID,
) error {
// get SMTP configuration
smtpConfigID, err := cTemplate.SMTPConfigurationID.Get()
if err != nil {
return errors.New("failed to get SMTP configuration from template")
}
smtpConfig, err := c.SMTPConfigService.GetByID(
ctx,
session, // use the actual session passed to the method
&smtpConfigID,
&repository.SMTPConfigurationOption{
WithHeaders: true,
},
)
if err != nil {
c.Logger.Errorw("smtp configuration did not load", "error", err)
return errs.Wrap(err)
}
smtpPort, err := smtpConfig.Port.Get()
if err != nil {
return errs.Wrap(err)
}
smtpHost, err := smtpConfig.Host.Get()
if err != nil {
return errs.Wrap(err)
}
smtpIgnoreCertErrors, err := smtpConfig.IgnoreCertErrors.Get()
if err != nil {
return errs.Wrap(err)
}
// setup SMTP client options
emailOptions := []mail.Option{
mail.WithPort(smtpPort.Int()),
mail.WithTLSConfig(
&tls.Config{
ServerName: smtpHost.String(),
InsecureSkipVerify: smtpIgnoreCertErrors,
},
),
}
// setup authentication if provided
username, err := smtpConfig.Username.Get()
if err != nil {
return errs.Wrap(err)
}
password, err := smtpConfig.Password.Get()
if err != nil {
return errs.Wrap(err)
}
if un := username.String(); len(un) > 0 {
emailOptions = append(emailOptions, mail.WithUsername(un))
if pw := password.String(); len(pw) > 0 {
emailOptions = append(emailOptions, mail.WithPassword(pw))
}
}
// create message
messageOptions := []mail.MsgOption{
mail.WithNoDefaultUserAgent(),
}
m := mail.NewMsg(messageOptions...)
// set envelope from
err = m.EnvelopeFrom(email.MailEnvelopeFrom.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to set envelope from", "error", err)
return errs.Wrap(err)
}
// set headers
err = m.From(email.MailHeaderFrom.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to set mail header 'From'", "error", err)
return errs.Wrap(err)
}
recpEmail := campaignRecipient.Recipient.Email.MustGet().String()
err = m.To(recpEmail)
if err != nil {
c.Logger.Errorw("failed to set mail header 'To'", "error", err)
return errs.Wrap(err)
}
// custom headers
if headers := smtpConfig.Headers; headers != nil {
for _, header := range headers {
key := header.Key.MustGet()
value := header.Value.MustGet()
m.SetGenHeader(
mail.Header(key.String()),
value.String(),
)
}
}
// setup template variables
urlIdentifier := cTemplate.URLIdentifier
if urlIdentifier == nil {
return errors.New("url identifier must be loaded for the campaign template")
}
// get template domain for assets and tracking pixel
domainName, err := domain.Name.Get()
if err != nil {
return errs.Wrap(err)
}
urlPath := cTemplate.URLPath.MustGet().String()
// generate custom campaign URL if first page is MITM
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url", "error", err)
return errs.Wrap(err)
}
t := c.TemplateService.CreateMail(
ctx,
domainName.String(),
urlIdentifier.Name.MustGet(),
urlPath,
campaignRecipient,
email,
nil,
campaignCompanyID,
)
// override campaign URL if it's different from template domain URL
templateURL := fmt.Sprintf("https://%s%s?%s=%s", domainName.String(), urlPath, urlIdentifier.Name.MustGet(), recipientID.String())
if customCampaignURL != templateURL {
(*t)["URL"] = customCampaignURL
}
// process subject through template
subjectTemplate, err := template.New("subject").Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)).Parse(email.MailHeaderSubject.MustGet().String())
if err != nil {
c.Logger.Errorw("failed to parse subject template", "error", err)
return errs.Wrap(err)
}
var subjectBuffer bytes.Buffer
err = subjectTemplate.Execute(&subjectBuffer, t)
if err != nil {
c.Logger.Errorw("failed to execute subject template", "error", err)
return errs.Wrap(err)
}
m.Subject(subjectBuffer.String())
var bodyBuffer bytes.Buffer
err = mailTmpl.Execute(&bodyBuffer, t)
if err != nil {
c.Logger.Errorw("failed to execute mail template", "error", err)
return errs.Wrap(err)
}
m.SetBodyString("text/html", bodyBuffer.String())
// handle attachments
attachments := email.Attachments
for _, emailAttachment := range attachments {
attachment := emailAttachment.Attachment
p, err := c.MailService.AttachmentService.GetPath(attachment)
if err != nil {
return fmt.Errorf("failed to get attachment path: %s", err)
}
// check if attachment should be inline (for cid: references)
if emailAttachment.IsInline {
// inline attachment - embedded in email body, can be referenced via cid:filename
if !attachment.EmbeddedContent.MustGet() {
// simple inline image - no template processing needed
m.EmbedFile(p.String())
} else {
// inline image with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
UpdatedAt: email.UpdatedAt,
Name: email.Name,
MailEnvelopeFrom: email.MailEnvelopeFrom,
MailHeaderFrom: email.MailHeaderFrom,
MailHeaderSubject: email.MailHeaderSubject,
Content: email.Content,
AddTrackingPixel: email.AddTrackingPixel,
CompanyID: email.CompanyID,
Attachments: email.Attachments,
Company: email.Company,
}
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
&attachmentAsEmail,
nil,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
}
// use EmbedReader for inline images - sets Content-Disposition: inline and Content-ID header
// the filename becomes the Content-ID, so use <img src="cid:filename.jpg"> in HTML
m.EmbedReader(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
} else if !attachment.EmbeddedContent.MustGet() {
// regular attachment - shows in attachment list
m.AttachFile(p.String())
} else {
// regular attachment with template processing
attachmentContent, err := os.ReadFile(p.String())
if err != nil {
return errs.Wrap(err)
}
// setup attachment for executing as email template
attachmentAsEmail := model.Email{
ID: email.ID,
CreatedAt: email.CreatedAt,
UpdatedAt: email.UpdatedAt,
Name: email.Name,
MailEnvelopeFrom: email.MailEnvelopeFrom,
MailHeaderFrom: email.MailHeaderFrom,
MailHeaderSubject: email.MailHeaderSubject,
Content: email.Content,
AddTrackingPixel: email.AddTrackingPixel,
CompanyID: email.CompanyID,
Attachments: email.Attachments,
Company: email.Company,
}
attachmentAsEmail.Content = nullable.NewNullableWithValue(
*vo.NewUnsafeOptionalString1MB(string(attachmentContent)),
)
// generate custom campaign URL for attachment
recipientID := campaignRecipient.ID.MustGet()
customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID)
if err != nil {
c.Logger.Errorw("failed to get campaign url for attachment", "error", err)
return errs.Wrap(err)
}
attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL(
ctx,
urlIdentifier.Name.MustGet(),
urlPath,
domain,
campaignRecipient,
&attachmentAsEmail,
nil,
customCampaignURL,
campaignCompanyID,
)
if err != nil {
return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err))
}
m.AttachReadSeeker(
filepath.Base(p.String()),
strings.NewReader(attachmentStr),
)
}
}
// send the email
var mc *mail.Client
// try different authentication methods
if un := username.String(); len(un) > 0 {
// try CRAM-MD5 first when credentials are provided
emailOptionsCRAM5 := append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsCRAM5...)
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, m)
// check if it's an authentication error and try PLAIN auth
if err != nil && (strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "534 ") ||
strings.Contains(err.Error(), "538 ") ||
strings.Contains(err.Error(), "CRAM-MD5") ||
strings.Contains(err.Error(), "authentication failed")) {
c.Logger.Debugf("CRAM-MD5 authentication failed, trying PLAIN auth", "error", err)
emailOptionsBasic := emailOptions
emailOptionsBasic = append(emailOptions, mail.WithSMTPAuth(mail.SMTPAuthPlain))
mc, _ = mail.NewClient(smtpHost.String(), emailOptionsBasic...)
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, m)
}
} else {
// no credentials provided, try without authentication
mc, _ = mail.NewClient(smtpHost.String(), emailOptions...)
mc.SetLogger(log.NewGoMailLoggerAdapter(c.Logger))
mc.SetDebugLog(true)
if build.Flags.Production {
mc.SetTLSPolicy(mail.TLSMandatory)
} else {
mc.SetTLSPolicy(mail.TLSOpportunistic)
}
err = mc.DialAndSendWithContext(ctx, m)
// if no-auth fails and we get an auth-related error, log it appropriately
if err != nil && (strings.Contains(err.Error(), "530 ") ||
strings.Contains(err.Error(), "535 ") ||
strings.Contains(err.Error(), "authentication required") ||
strings.Contains(err.Error(), "AUTH")) {
c.Logger.Warnw("Server requires authentication but no credentials provided", "error", err)
}
}
if err != nil {
c.Logger.Errorw("failed to send email", "error", err)
return errs.Wrap(err)
}
return nil
}
// SetNotableCampaignEvent checks and update if most notable event for a campaign
func (c *Campaign) setMostNotableCampaignEvent(
ctx context.Context,
campaign *model.Campaign,
eventName string,
) error {
currentEventID, _ := campaign.NotableEventID.Get()
notableEventID, _ := cache.EventIDByName[eventName]
if cache.IsMoreNotableCampaignRecipientEventID(
&currentEventID,
notableEventID,
) {
campaign.NotableEventID.Set(*notableEventID)
cid := campaign.ID.MustGet()
err := c.CampaignRepository.UpdateByID(
ctx,
&cid,
campaign,
)
if err != nil {
c.Logger.Errorw("failed to update notable campaign event", "error", err)
return errs.Wrap(err)
}
}
return nil
}
// SetNotableCampaignRecipientEvent checks and update if most notable event for campaign recipient
func (c *Campaign) SetNotableCampaignRecipientEvent(
ctx context.Context,
campaignRecipient *model.CampaignRecipient,
eventName string,
) error {
currentNotableEventID, _ := campaignRecipient.NotableEventID.Get()
notableEventID, _ := cache.EventIDByName[eventName]
if cache.IsMoreNotableCampaignRecipientEventID(
&currentNotableEventID,
notableEventID,
) {
campaignRecipient.NotableEventID.Set(*notableEventID)
crid := campaignRecipient.ID.MustGet()
err := c.CampaignRecipientRepository.UpdateByID(
ctx,
&crid,
campaignRecipient,
)
if err != nil {
c.Logger.Errorw("failed to save updating notable campaign recipient event", "error", err)
return errs.Wrap(err)
}
}
return nil
}
// GenerateCampaignStats generates and stores campaign statistics when a campaign is closed
func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Session, campaignID *uuid.UUID) error {
c.Logger.Debugw("starting campaign stats generation", "campaignID", campaignID.String())
// Check if stats already exist for this campaign to prevent duplicates
existingStats, err := c.CampaignRepository.GetCampaignStats(ctx, campaignID)
if err == nil && existingStats != nil {
c.Logger.Debugw("campaign stats already exist, skipping generation", "campaignID", campaignID.String())
return nil
}
// Continue if record not found or table doesn't exist (which is expected for new stats)
c.Logger.Debugw("no existing stats found, proceeding with generation", "campaignID", campaignID.String(), "checkError", err)
// Get the campaign without joins to avoid SQL ambiguity
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
if err != nil {
c.Logger.Errorw("failed to get campaign for stats", "error", err, "campaignID", campaignID.String())
return errs.Wrap(err)
}
campaignName := campaign.Name.MustGet().String()
c.Logger.Debugf("retrieved campaign for stats", "campaignID", campaignID.String(), "campaignName", campaignName)
// Get campaign result stats (existing method)
resultStats, err := c.CampaignRepository.GetResultStats(ctx, campaignID)
if err != nil {
c.Logger.Errorw("failed to get result stats", "error", err, "campaignID", campaignID.String())
return errs.Wrap(err)
}
c.Logger.Debugf("retrieved result stats", "campaignID", campaignID.String(), "recipients", resultStats.Recipients)
// Determine campaign type
campaignType := "scheduled"
if campaign.SendStartAt == nil && campaign.SendEndAt == nil {
campaignType = "self-managed"
}
// Get template name with proper session
templateName := ""
templateID := campaign.TemplateID.MustGet()
template, err := c.CampaignTemplateService.GetByID(ctx, session, &templateID, &repository.CampaignTemplateOption{})
if err == nil && template != nil && !template.Name.IsNull() {
templateName = template.Name.MustGet().String()
}
// Create time pointers
now := time.Now()
var companyID *uuid.UUID
if !campaign.CompanyID.IsNull() {
id := campaign.CompanyID.MustGet()
companyID = &id
}
// companyID can be nil for global campaigns
var sendStartAt *time.Time
if !campaign.SendStartAt.IsNull() {
t := campaign.SendStartAt.MustGet()
sendStartAt = &t
}
var sendEndAt *time.Time
if !campaign.SendEndAt.IsNull() {
t := campaign.SendEndAt.MustGet()
sendEndAt = &t
}
var closedAt *time.Time
if !campaign.ClosedAt.IsNull() {
t := campaign.ClosedAt.MustGet()
closedAt = &t
}
// Create campaign stats record
id := uuid.New()
stats := &database.CampaignStats{
ID: &id,
CampaignID: campaignID,
CampaignName: campaignName,
CompanyID: companyID,
CampaignStartDate: sendStartAt,
CampaignEndDate: sendEndAt,
CampaignClosedAt: closedAt,
TotalRecipients: int(resultStats.Recipients),
TotalEvents: 0, // Will be calculated from events
EmailsSent: int(resultStats.EmailsSent),
TrackingPixelLoaded: int(resultStats.TrackingPixelLoaded),
WebsiteVisits: int(resultStats.WebsiteLoaded),
DataSubmissions: int(resultStats.SubmittedData),
Reported: int(resultStats.Reported),
TemplateName: templateName,
CampaignType: campaignType,
CreatedAt: &now,
UpdatedAt: &now,
}
// Insert the stats
c.Logger.Debugf("inserting campaign stats", "campaignID", campaignID.String(), "statsID", stats.ID.String())
err = c.CampaignRepository.InsertCampaignStats(ctx, stats)
if err != nil {
c.Logger.Errorw("failed to insert campaign stats", "error", err, "campaignID", campaignID.String())
return errs.Wrap(err)
}
c.Logger.Debugf("successfully inserted campaign stats", "campaignID", campaignID.String(), "statsID", stats.ID.String())
return nil
}
// GetCampaignStats retrieves campaign statistics by campaign ID
func (c *Campaign) GetCampaignStats(ctx context.Context, session *model.Session, campaignID *uuid.UUID) (*database.CampaignStats, error) {
// 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 {
return nil, errs.ErrAuthorizationFailed
}
return c.CampaignRepository.GetCampaignStats(ctx, campaignID)
}
// GetAllCampaignStats retrieves all campaign statistics
func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Session, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
result := model.NewEmptyResult[database.CampaignStats]()
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
return result, errs.ErrAuthorizationFailed
}
// Get the data
stats, err := c.CampaignRepository.GetAllCampaignStats(ctx, companyID)
if err != nil {
return result, errs.Wrap(err)
}
// Convert to result format with pointers
rows := make([]*database.CampaignStats, len(stats))
for i := range stats {
rows[i] = &stats[i]
}
result.Rows = rows
result.HasNextPage = false
return result, nil
}
// CreateManualCampaignStats creates campaign statistics manually without requiring a campaign
func (c *Campaign) CreateManualCampaignStats(ctx context.Context, session *model.Session, req *database.CampaignStats) (*database.CampaignStats, error) {
// 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 {
return nil, errs.ErrAuthorizationFailed
}
// Set up the stats record
id := uuid.New()
now := time.Now()
// Use provided date for created_at and updated_at, or current time if not provided
var statsDate time.Time
if req.CampaignStartDate != nil {
statsDate = *req.CampaignStartDate
} else {
statsDate = now
}
// Set required fields
req.ID = &id
req.CreatedAt = &statsDate
req.UpdatedAt = &statsDate
req.CampaignID = nil // No campaign reference for manual stats
// Calculate total events
req.TotalEvents = req.EmailsSent + req.TrackingPixelLoaded + req.WebsiteVisits + req.DataSubmissions + req.Reported
// Insert the stats
c.Logger.Debugf("inserting manual campaign stats", "statsID", req.ID.String())
err = c.CampaignRepository.InsertCampaignStats(ctx, req)
if err != nil {
c.Logger.Errorw("failed to insert manual campaign stats", "error", err, "statsID", req.ID.String())
return nil, errs.Wrap(err)
}
c.Logger.Debugf("successfully inserted manual campaign stats", "statsID", req.ID.String())
return req, nil
}
// GetManualCampaignStats retrieves manual campaign statistics (those without campaignID)
func (c *Campaign) GetManualCampaignStats(ctx context.Context, session *model.Session, companyID *uuid.UUID) (*model.Result[database.CampaignStats], error) {
result := model.NewEmptyResult[database.CampaignStats]()
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
return result, errs.ErrAuthorizationFailed
}
// Get manual stats (those with null campaignID)
stats, err := c.CampaignRepository.GetManualCampaignStats(ctx, companyID)
if err != nil {
return result, errs.Wrap(err)
}
// Convert to result format with pointers
rows := make([]*database.CampaignStats, len(stats))
for i := range stats {
rows[i] = &stats[i]
}
result.Rows = rows
result.HasNextPage = false
return result, nil
}
// UpdateManualCampaignStats updates manual campaign statistics
func (c *Campaign) UpdateManualCampaignStats(ctx context.Context, session *model.Session, req *database.CampaignStats) (*database.CampaignStats, error) {
// 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 {
return nil, errs.ErrAuthorizationFailed
}
// Get existing stats to ensure it's manual (no campaignID)
existingStats, err := c.CampaignRepository.GetCampaignStatsByID(ctx, req.ID)
if err != nil {
return nil, errs.Wrap(err)
}
// Ensure this is a manual stat (no campaignID)
if existingStats.CampaignID != nil {
return nil, errs.Wrap(errors.New("cannot update system-generated campaign stats"))
}
// Set updated timestamp
now := time.Now()
req.UpdatedAt = &now
req.CampaignID = nil // Ensure it remains manual
// Calculate total events
req.TotalEvents = req.EmailsSent + req.TrackingPixelLoaded + req.WebsiteVisits + req.DataSubmissions + req.Reported
// Prepare update data
updateData := map[string]interface{}{
"updated_at": req.UpdatedAt,
"campaign_name": req.CampaignName,
"company_id": req.CompanyID,
"campaign_start_date": req.CampaignStartDate,
"campaign_end_date": req.CampaignEndDate,
"campaign_closed_at": req.CampaignClosedAt,
"total_recipients": req.TotalRecipients,
"total_events": req.TotalEvents,
"emails_sent": req.EmailsSent,
"tracking_pixel_loaded": req.TrackingPixelLoaded,
"website_visits": req.WebsiteVisits,
"data_submissions": req.DataSubmissions,
"reported": req.Reported,
"template_name": req.TemplateName,
"campaign_type": req.CampaignType,
}
// Update the stats
err = c.CampaignRepository.UpdateCampaignStats(ctx, req.ID, updateData)
if err != nil {
c.Logger.Errorw("failed to update manual campaign stats", "error", err, "statsID", req.ID.String())
return nil, errs.Wrap(err)
}
c.Logger.Debugf("successfully updated manual campaign stats", "statsID", req.ID.String())
return req, nil
}
// DeleteManualCampaignStats deletes manual campaign statistics by ID
func (c *Campaign) DeleteManualCampaignStats(ctx context.Context, session *model.Session, statsID *uuid.UUID) error {
// Check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
c.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
return errs.ErrAuthorizationFailed
}
// Get existing stats to ensure it's manual (no campaignID)
existingStats, err := c.CampaignRepository.GetCampaignStatsByID(ctx, statsID)
if err != nil {
return errs.Wrap(err)
}
// Ensure this is a manual stat (no campaignID)
if existingStats.CampaignID != nil {
return errs.Wrap(errors.New("cannot delete system-generated campaign stats"))
}
// Delete the stats
err = c.CampaignRepository.DeleteCampaignStatsByID(ctx, statsID)
if err != nil {
c.Logger.Errorw("failed to delete manual campaign stats", "error", err, "statsID", statsID.String())
return errs.Wrap(err)
}
c.Logger.Debugf("successfully deleted manual campaign stats", "statsID", statsID.String())
return nil
}
// ProcessReportedCSV processes a CSV file with reported recipients
func (c *Campaign) ProcessReportedCSV(
ctx context.Context,
session *model.Session,
campaignID *uuid.UUID,
records [][]string,
emailColumnIndex int,
dateColumnIndex int,
) (int, int, error) {
ae := NewAuditEvent("Campaign.ProcessReportedCSV", 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 0, 0, errs.Wrap(err)
}
if !isAuthorized {
c.AuditLogNotAuthorized(ae)
return 0, 0, errs.ErrAuthorizationFailed
}
// get campaign to check it exists and get details
campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{})
if err != nil {
c.Logger.Errorw("failed to get campaign by id", "error", err)
return 0, 0, errs.Wrap(err)
}
if len(records) < 2 {
return 0, 0, errs.NewValidationError(errors.New("CSV must have at least a header row and one data row"))
}
// validate column indices
if emailColumnIndex < 0 || emailColumnIndex >= len(records[0]) {
c.Logger.Errorw("invalid email column index", "index", emailColumnIndex, "columnCount", len(records[0]))
return 0, 0, errs.NewValidationError(errors.New("invalid email column index"))
}
if dateColumnIndex < 0 || dateColumnIndex >= len(records[0]) {
c.Logger.Errorw("invalid date column index", "index", dateColumnIndex, "columnCount", len(records[0]))
return 0, 0, errs.NewValidationError(errors.New("invalid date column index"))
}
reportedByIndex := emailColumnIndex
dateReportedIndex := dateColumnIndex
c.Logger.Infow("processing CSV columns", "emailColumn", reportedByIndex, "dateColumn", dateReportedIndex)
processed := 0
skipped := 0
reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED]
// process each row
for i, record := range records[1:] { // skip header
if len(record) <= reportedByIndex || len(record) <= dateReportedIndex {
skipped++
c.Logger.Debugw("skipping row with insufficient columns", "row", i+2)
continue
}
reportedByEmail := strings.TrimSpace(record[reportedByIndex])
dateReported := strings.TrimSpace(record[dateReportedIndex])
if reportedByEmail == "" {
skipped++
c.Logger.Debugw("skipping row with empty email", "row", i+2)
continue
}
// parse date - try multiple formats and handle timezone
var parsedDate time.Time
dateFormats := []string{
// iso 8601 with fractional seconds (microsoft format)
"2006-01-02T15:04:05.999999999Z",
"2006-01-02T15:04:05.999999999-07:00",
"2006-01-02T15:04:05.999999999+02:00",
"2006-01-02T15:04:05.999999999+01:00",
// iso 8601 without fractional seconds
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05+02:00",
"2006-01-02T15:04:05+01:00",
"2006-01-02T15:04:05",
// other common formats
"2006-01-02 15:04:05",
"2006-01-02",
"01/02/2006 15:04:05",
"01/02/2006",
"02-01-2006 15:04:05",
"02-01-2006",
"2006/01/02 15:04:05",
"2006/01/02",
}
dateParseError := true
for _, format := range dateFormats {
if pd, err := time.Parse(format, dateReported); err == nil {
parsedDate = pd
dateParseError = false
break
}
}
if dateParseError {
skipped++
c.Logger.Debugw("skipping row with invalid date format", "row", i+2, "date", dateReported, "tried_formats", dateFormats)
continue
}
c.Logger.Debugw("processing row", "row", i+2, "email", reportedByEmail, "date", parsedDate)
// find recipient by email in this campaign
emailVO, err := vo.NewEmail(reportedByEmail)
if err != nil {
skipped++
c.Logger.Debugw("invalid email format", "email", reportedByEmail, "row", i+2)
continue
}
// Get campaign to check company context
companyID, _ := campaign.CompanyID.Get()
var companyPtr *uuid.UUID
if companyID != uuid.Nil {
companyPtr = &companyID
}
recipient, err := c.RecipientService.GetByEmail(ctx, session, emailVO, companyPtr)
if err != nil {
skipped++
c.Logger.Debugw("recipient not found for email", "email", reportedByEmail, "row", i+2)
continue
}
recipientID := recipient.ID.MustGet()
// check if recipient is part of this campaign
campaignRecipient, err := c.CampaignRecipientRepository.GetByCampaignAndRecipientID(
ctx,
campaignID,
&recipientID,
&repository.CampaignRecipientOption{},
)
if err != nil {
skipped++
c.Logger.Debugw("recipient not part of campaign", "email", reportedByEmail, "campaignID", campaignID.String(), "row", i+2)
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,
)
alreadyReported := false
if err == nil && existingEvent != nil {
for _, event := range existingEvent.Rows {
if event.RecipientID != nil && *event.RecipientID == recipientID {
alreadyReported = true
break
}
}
}
if alreadyReported {
skipped++
c.Logger.Debugw("recipient already reported", "email", reportedByEmail, "campaignID", campaignID.String())
continue
}
// create campaign event for reported
eventID := uuid.New()
var campaignEvent *model.CampaignEvent
if campaign.IsAnonymous.MustGet() {
campaignEvent = &model.CampaignEvent{
ID: &eventID,
CampaignID: campaignID,
RecipientID: nil,
IP: vo.NewEmptyOptionalString64(),
UserAgent: vo.NewEmptyOptionalString255(),
EventID: reportedEventID,
Data: vo.NewEmptyOptionalString1MB(),
Metadata: vo.NewEmptyOptionalString1MB(),
}
} else {
campaignEvent = &model.CampaignEvent{
ID: &eventID,
CampaignID: campaignID,
RecipientID: &recipientID,
IP: vo.NewEmptyOptionalString64(),
UserAgent: vo.NewEmptyOptionalString255(),
EventID: reportedEventID,
Data: vo.NewEmptyOptionalString1MB(),
Metadata: vo.NewEmptyOptionalString1MB(),
}
}
// save the event with custom timestamp
err = c.saveReportedEvent(campaignEvent, parsedDate)
if err != nil {
c.Logger.Errorw("failed to save reported event", "error", err, "email", reportedByEmail)
skipped++
continue
}
// update most notable event for campaign recipient
err = c.SetNotableCampaignRecipientEvent(
ctx,
campaignRecipient,
data.EVENT_CAMPAIGN_RECIPIENT_REPORTED,
)
if err != nil {
c.Logger.Errorw("failed to update notable event", "error", err)
}
processed++
}
ae.Details["processed"] = processed
ae.Details["skipped"] = skipped
c.AuditLogAuthorized(ae)
return processed, skipped, nil
}
// saveReportedEvent saves a reported event with custom timestamp
func (c *Campaign) saveReportedEvent(
campaignEvent *model.CampaignEvent,
customTime time.Time,
) error {
row := map[string]any{
"id": campaignEvent.ID.String(),
"event_id": campaignEvent.EventID.String(),
"campaign_id": campaignEvent.CampaignID.String(),
"ip_address": campaignEvent.IP.String(),
"user_agent": campaignEvent.UserAgent.String(),
"data": campaignEvent.Data.String(),
"created_at": customTime,
"updated_at": time.Now(),
}
if campaignEvent.RecipientID != nil {
row["recipient_id"] = campaignEvent.RecipientID.String()
}
res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row)
if res.Error != nil {
return res.Error
}
return nil
}