listmonk/internal/core/campaigns.go
Kailash Nadh 3b9a0f782e
Add support for file attachments on campaigns (#1341)
- Adds support for arbitrary file uploads with an admin setting to select allowed file extensions.
- Adds support for attaching media (files) to campaigns.
2023-05-18 16:55:59 +05:30

426 lines
14 KiB
Go

package core
import (
"database/sql"
"net/http"
"time"
"github.com/gofrs/uuid"
"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 []string, orderBy, order string, offset, limit int) (models.Campaigns, int, error) {
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns)
if statuses == nil {
statuses = []string{}
}
// Unsafe to ignore scanning fields not present in models.Campaigns.
var out models.Campaigns
if err := c.db.Select(&out, stmt, 0, pq.Array(statuses), 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 string) (models.Campaign, error) {
return c.getCampaign(id, uuid, campaignTplDefault)
}
// GetArchivedCampaign retreives a campaign with the archive template body.
func (c *Core) GetArchivedCampaign(id int, uuid string) (models.Campaign, error) {
out, err := c.getCampaign(id, uuid, 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 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, 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.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.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) error {
if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, 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 {
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
}