package core import ( "database/sql" "net/http" "time" "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/lib/pq" ) const ( CampaignAnalyticsViews = "views" CampaignAnalyticsClicks = "clicks" CampaignAnalyticsBounces = "bounces" campaignTplDefault = "default" campaignTplArchive = "archive" ) // QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary // query expression. It also returns the total number of records in the DB. func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, offset, limit int) (models.Campaigns, int, error) { queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields) if statuses == nil { statuses = []string{} } if tags == nil { tags = []string{} } // Unsafe to ignore scanning fields not present in models.Campaigns. var out models.Campaigns if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, offset, limit); err != nil { c.log.Printf("error fetching campaigns: %v", err) return nil, 0, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } for i := 0; i < len(out); i++ { // Replace null tags. if out[i].Tags == nil { out[i].Tags = []string{} } } // Lazy load stats. if err := out.LoadStats(c.q.GetCampaignStats); err != nil { c.log.Printf("error fetching campaign stats: %v", err) return nil, 0, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err))) } total := 0 if len(out) > 0 { total = out[0].Total } return out, total, nil } // GetCampaign retrieves a campaign. func (c *Core) GetCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) { return c.getCampaign(id, uuid, archiveSlug, campaignTplDefault) } // GetArchivedCampaign retrieves a campaign with the archive template body. func (c *Core) GetArchivedCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) { out, err := c.getCampaign(id, uuid, archiveSlug, campaignTplArchive) if err != nil { return out, err } if !out.Archive { return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) } return out, nil } // getCampaign retrieves a campaign. If typlType=default, then the campaign's // template body is returned as "template_body". If tplType="archive", // the archive template is returned. func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType string) (models.Campaign, error) { // Unsafe to ignore scanning fields not present in models.Campaigns. var uu interface{} if uuid != "" { uu = uuid } var out models.Campaigns if err := c.q.GetCampaign.Select(&out, id, uu, archiveSlug, tplType); err != nil { // if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil { c.log.Printf("error fetching campaign: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if len(out) == 0 { return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) } for i := 0; i < len(out); i++ { // Replace null tags. if out[i].Tags == nil { out[i].Tags = []string{} } } // Lazy load stats. if err := out.LoadStats(c.q.GetCampaignStats); err != nil { c.log.Printf("error fetching campaign stats: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return out[0], nil } // GetCampaignForPreview retrieves a campaign with a template body. func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) { var out models.Campaign if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil { if err == sql.ErrNoRows { return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) } c.log.Printf("error fetching campaign: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return out, nil } // GetArchivedCampaigns retrieves campaigns with a template body. func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns, int, error) { var out models.Campaigns if err := c.q.GetArchivedCampaigns.Select(&out, offset, limit, campaignTplArchive); err != nil { c.log.Printf("error fetching public campaigns: %v", err) return models.Campaigns{}, 0, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } total := 0 if len(out) > 0 { total = out[0].Total } return out, total, nil } // CreateCampaign creates a new campaign. func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) { uu, err := uuid.NewV4() if err != nil { c.log.Printf("error generating UUID: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) } // Insert and read ID. var newID int if err := c.q.CreateCampaign.Get(&newID, uu, o.Type, o.Name, o.Subject, o.FromEmail, o.Body, o.AltBody, o.ContentType, o.SendAt, o.Headers, pq.StringArray(normalizeTags(o.Tags)), o.Messenger, o.TemplateID, pq.Array(listIDs), o.Archive, o.ArchiveSlug, o.ArchiveTemplateID, o.ArchiveMeta, pq.Array(mediaIDs), ); err != nil { if err == sql.ErrNoRows { return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs")) } c.log.Printf("error creating campaign: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } out, err := c.GetCampaign(newID, "", "") if err != nil { return models.Campaign{}, err } return out, nil } // UpdateCampaign updates a campaign. func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, mediaIDs []int, sendLater bool) (models.Campaign, error) { _, err := c.q.UpdateCampaign.Exec(id, o.Name, o.Subject, o.FromEmail, o.Body, o.AltBody, o.ContentType, o.SendAt, sendLater, o.Headers, pq.StringArray(normalizeTags(o.Tags)), o.Messenger, o.TemplateID, pq.Array(listIDs), o.Archive, o.ArchiveSlug, o.ArchiveTemplateID, o.ArchiveMeta, pq.Array(mediaIDs)) if err != nil { c.log.Printf("error updating campaign: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } out, err := c.GetCampaign(id, "", "") if err != nil { return models.Campaign{}, err } return out, nil } // UpdateCampaignStatus updates a campaign's status, eg: draft to running. func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) { cm, err := c.GetCampaign(id, "", "") if err != nil { return models.Campaign{}, err } errMsg := "" switch status { case models.CampaignStatusDraft: if cm.Status != models.CampaignStatusScheduled { errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft") } case models.CampaignStatusScheduled: if cm.Status != models.CampaignStatusDraft { errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled") } if !cm.SendAt.Valid { errMsg = c.i18n.T("campaigns.needsSendAt") } case models.CampaignStatusRunning: if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft { errMsg = c.i18n.T("campaigns.onlyPausedDraft") } case models.CampaignStatusPaused: if cm.Status != models.CampaignStatusRunning { errMsg = c.i18n.T("campaigns.onlyActivePause") } case models.CampaignStatusCancelled: if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused { errMsg = c.i18n.T("campaigns.onlyActiveCancel") } } if len(errMsg) > 0 { return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg) } res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status) if err != nil { c.log.Printf("error updating campaign status: %v", err) return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if n, _ := res.RowsAffected(); n == 0 { return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } cm.Status = status return cm, nil } // UpdateCampaignArchive updates a campaign's archive properties. func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta models.JSON, archiveSlug string) error { if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, archiveSlug, tplID, meta); err != nil { c.log.Printf("error updating campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return nil } // DeleteCampaign deletes a campaign. func (c *Core) DeleteCampaign(id int) error { res, err := c.q.DeleteCampaign.Exec(id) if err != nil { c.log.Printf("error deleting campaign: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } if n, _ := res.RowsAffected(); n == 0 { return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) } return nil } // GetRunningCampaignStats returns the progress stats of running campaigns. func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) { out := []models.CampaignStats{} if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil { if err == sql.ErrNoRows { return nil, nil } c.log.Printf("error fetching campaign stats: %v", err) return nil, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } else if len(out) == 0 { return nil, nil } return out, nil } func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) { // Pick campaign view counts or click counts. var stmt *sqlx.Stmt switch typ { case "views": stmt = c.q.GetCampaignViewCounts case "clicks": stmt = c.q.GetCampaignClickCounts case "bounces": stmt = c.q.GetCampaignBounceCounts default: return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData")) } if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) { return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates")) } out := []models.CampaignAnalyticsCount{} if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil { c.log.Printf("error fetching campaign %s: %v", typ, err) return nil, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err))) } return out, nil } // GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs. func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) { out := []models.CampaignAnalyticsLink{} if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil { c.log.Printf("error fetching campaign %s: %v", typ, err) return nil, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err))) } return out, nil } // RegisterCampaignView registers a subscriber's view on a campaign. func (c *Core) RegisterCampaignView(campUUID, subUUID string) error { if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "campaign_id" { return nil } c.log.Printf("error registering campaign view: %s", err) return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) } return nil } // RegisterCampaignLinkClick registers a subscriber's link click on a campaign. func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) (string, error) { var url string if err := c.q.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" { return "", echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink")) } c.log.Printf("error registering link click: %s", err) return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest")) } return url, nil } // DeleteCampaignViews deletes campaign views older than a given date. func (c *Core) DeleteCampaignViews(before time.Time) error { if _, err := c.q.DeleteCampaignViews.Exec(before); err != nil { c.log.Printf("error deleting campaign views: %s", err) return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest")) } return nil } // DeleteCampaignLinkClicks deletes campaign views older than a given date. func (c *Core) DeleteCampaignLinkClicks(before time.Time) error { if _, err := c.q.DeleteCampaignLinkClicks.Exec(before); err != nil { c.log.Printf("error deleting campaign link clicks: %s", err) return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest")) } return nil }