listmonk/cmd/campaigns.go
Kailash Nadh c1f81cfadd Fix compatibility issues with master.
- Fix merge conflicts.
- Simply logic in campaign preview handler.
- Remove redundant `GetCampaignForPreviewWithTemplate()` and switch to the old
  query that optionally takes a template.
- Fix Vue linting issues.
2025-04-20 20:48:51 +05:30

688 lines
19 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/internal/notifs"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
"gopkg.in/volatiletech/null.v6"
)
// campReq is a wrapper over the Campaign model for receiving
// campaign creation and update data from APIs.
type campReq struct {
models.Campaign
// This overrides Campaign.Lists to receive and
// write a list of int IDs during creation and updation.
// Campaign.Lists is JSONText for sending lists children
// to the outside world.
ListIDs []int `json:"lists"`
MediaIDs []int `json:"media"`
// This is only relevant to campaign test requests.
SubscriberEmails pq.StringArray `json:"subscribers"`
}
// campContentReq wraps params coming from API requests for converting
// campaign content formats.
type campContentReq struct {
models.Campaign
From string `json:"from"`
To string `json:"to"`
}
var (
reFromAddress = regexp.MustCompile(`((.+?)\s)?<(.+?)@(.+?)>`)
reSlug = regexp.MustCompile(`[^\p{L}\p{M}\p{N}]`)
)
// GetCampaigns handles retrieval of campaigns.
func (a *App) GetCampaigns(c echo.Context) error {
// Get the authenticated user.
user := auth.GetUser(c)
var (
hasAllPerm = user.HasPerm(auth.PermCampaignsGetAll)
permittedLists []int
)
if !hasAllPerm {
// Either the user has campaigns:get_all permissions and can view all campaigns,
// or the campaigns are filtered by the lists the user has get|manage access to.
hasAllPerm, permittedLists = user.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage)
}
var (
pg = a.pg.NewFromURL(c.Request().URL.Query())
status = c.QueryParams()["status"]
tags = c.QueryParams()["tag"]
query = strings.TrimSpace(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
// Query and retrieve campaigns from the DB.
res, total, err := a.core.QueryCampaigns(query, status, tags, orderBy, order, hasAllPerm, permittedLists, pg.Offset, pg.Limit)
if err != nil {
return err
}
// Remove the body from the response if requested.
if noBody {
for i := range res {
res[i].Body = ""
}
}
// Paginate the response.
if len(res) == 0 {
return c.JSON(http.StatusOK, okResp{models.PageResults{Results: []models.Campaign{}}})
}
out := models.PageResults{
Query: query,
Results: res,
Total: total,
Page: pg.Page,
PerPage: pg.PerPage,
}
return c.JSON(http.StatusOK, okResp{out})
}
// GetCampaign handles retrieval of campaigns.
func (a *App) GetCampaign(c echo.Context) error {
// Get the campaign ID.
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
return err
}
// Get the campaign from the DB.
out, err := a.core.GetCampaign(id, "", "")
if err != nil {
return err
}
// Blank out the body if requested.
noBody, _ := strconv.ParseBool(c.QueryParam("no_body"))
if noBody {
out.Body = ""
}
return c.JSON(http.StatusOK, okResp{out})
}
// PreviewCampaign renders the HTML preview of a campaign body.
func (a *App) PreviewCampaign(c echo.Context) error {
// Get the campaign ID.
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
return err
}
var (
isPost = c.Request().Method == http.MethodPost
contentType = c.FormValue("content_type")
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
)
// For visual content type don't use the template for preview, use only body.
if contentType == models.CampaignContentTypeVisual && tplID < 1 {
tplID = 0
}
// Get the campaign from the DB for previewing.
camp, err := a.core.GetCampaignForPreview(id, tplID)
if err != nil {
return err
}
// There's a body in the request to preview instead of the body in the DB.
if isPost {
camp.ContentType = contentType
camp.Body = c.FormValue("body")
}
// Use a dummy campaign ID to prevent views and clicks from {{ TrackView }}
// and {{ TrackLink }} being registered on preview.
camp.UUID = dummySubscriber.UUID
if err := camp.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil {
a.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
msg, err := a.manager.NewCampaignMessage(&camp, dummySubscriber)
if err != nil {
a.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
if camp.ContentType == models.CampaignContentTypePlain {
return c.String(http.StatusOK, string(msg.Body()))
}
return c.HTML(http.StatusOK, string(msg.Body()))
}
// CampaignContent handles campaign content (body) format conversions.
func (a *App) CampaignContent(c echo.Context) error {
var camp campContentReq
if err := c.Bind(&camp); err != nil {
return err
}
// Convert formats, eg: markdown to HTML.
out, err := camp.ConvertContent(camp.From, camp.To)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, okResp{out})
}
// CreateCampaign handles campaign creation.
// Newly created campaigns are always drafts.
func (a *App) CreateCampaign(c echo.Context) error {
var o campReq
if err := c.Bind(&o); err != nil {
return err
}
// If the campaign's 'opt-in', prepare a default message.
if o.Type == models.CampaignTypeOptin {
op, err := a.makeOptinCampaignMessage(o)
if err != nil {
return err
}
o = op
} else if o.Type == "" {
o.Type = models.CampaignTypeRegular
}
if o.Messenger == "" {
o.Messenger = "email"
}
// Validate.
if c, err := a.validateCampaignFields(o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
o = c
}
if o.ArchiveTemplateID.Valid && o.ArchiveTemplateID.Int != 0 {
o.ArchiveTemplateID = o.TemplateID
}
out, err := a.core.CreateCampaign(o.Campaign, o.ListIDs, o.MediaIDs)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// UpdateCampaign handles campaign modification.
// Campaigns that are done cannot be modified.
func (a *App) UpdateCampaign(c echo.Context) error {
// Get the campaign ID.
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
return err
}
// Retrieve the campaign from the DB.
cm, err := a.core.GetCampaign(id, "", "")
if err != nil {
return err
}
if !canEditCampaign(cm.Status) {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.cantUpdate"))
}
// Read the incoming params into the existing campaign fields from the DB.
// This allows updating of values that have been sent whereas fields
// that are not in the request retain the old values.
o := campReq{Campaign: cm}
if err := c.Bind(&o); err != nil {
return err
}
if c, err := a.validateCampaignFields(o); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
o = c
}
out, err := a.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.MediaIDs)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// UpdateCampaignStatus handles campaign status modification.
func (a *App) UpdateCampaignStatus(c echo.Context) error {
// Get the campaign ID.
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
return err
}
req := struct {
Status string `json:"status"`
}{}
if err := c.Bind(&req); err != nil {
return err
}
// Update the campaign status in the DB.
out, err := a.core.UpdateCampaignStatus(id, req.Status)
if err != nil {
return err
}
// If the campaign is being stopped, send the signal to the manager to stop it in flight.
if req.Status == models.CampaignStatusPaused || req.Status == models.CampaignStatusCancelled {
a.manager.StopCampaign(id)
}
return c.JSON(http.StatusOK, okResp{out})
}
// UpdateCampaignArchive handles campaign status modification.
func (a *App) UpdateCampaignArchive(c echo.Context) error {
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
return err
}
req := struct {
Archive bool `json:"archive"`
TemplateID int `json:"archive_template_id"`
Meta models.JSON `json:"archive_meta"`
ArchiveSlug string `json:"archive_slug"`
}{}
if err := c.Bind(&req); err != nil {
return err
}
if req.ArchiveSlug != "" {
// Format the slug to be alpha-numeric-dash.
s := strings.ToLower(req.ArchiveSlug)
s = strings.TrimSpace(reSlug.ReplaceAllString(s, " "))
s = regexpSpaces.ReplaceAllString(s, "-")
req.ArchiveSlug = s
}
if err := a.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta, req.ArchiveSlug); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{req})
}
// DeleteCampaign handles campaign deletion.
// Only scheduled campaigns that have not started yet can be deleted.
func (a *App) DeleteCampaign(c echo.Context) error {
// Get the campaign ID.
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
return err
}
// Delete the campaign from the DB.
if err := a.core.DeleteCampaign(id); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// GetRunningCampaignStats returns stats of a given set of campaign IDs.
func (a *App) GetRunningCampaignStats(c echo.Context) error {
// Get the running campaign stats from the DB.
out, err := a.core.GetRunningCampaignStats()
if err != nil {
return err
}
if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
// Compute rate.
for i, c := range out {
if c.Started.Valid && c.UpdatedAt.Valid {
diff := max(int(c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()), 1)
rate := c.Sent / diff
if rate > c.Sent || rate > c.ToSend {
rate = c.Sent
}
// Rate since the starting of the campaign.
out[i].NetRate = rate
// Realtime running rate over the last minute.
out[i].Rate = a.manager.GetCampaignStats(c.ID).SendRate
}
}
return c.JSON(http.StatusOK, okResp{out})
}
// TestCampaign handles the sending of a campaign message to
// arbitrary subscribers for testing.
func (a *App) TestCampaign(c echo.Context) error {
// Get the campaign ID.
id := getID(c)
// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeManage, id, c); err != nil {
return err
}
// Get and validate fields.
var req campReq
if err := c.Bind(&req); err != nil {
return err
}
// Validate.
if c, err := a.validateCampaignFields(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
req = c
}
if len(req.SubscriberEmails) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.noSubsToTest"))
}
// Sanitize subscriber e-mails.
for i := range req.SubscriberEmails {
req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i]))
}
// Get the subscribers from the DB by their e-mails.
subs, err := a.core.GetSubscribersByEmail(req.SubscriberEmails)
if err != nil {
return err
}
// Get the campaign from the DB for previewing.
tplID, _ := strconv.Atoi(c.FormValue("template_id"))
camp, err := a.core.GetCampaignForPreview(id, tplID)
if err != nil {
return err
}
// Override certain values from the DB with incoming values.
camp.Name = req.Name
camp.Subject = req.Subject
camp.FromEmail = req.FromEmail
camp.Body = req.Body
camp.AltBody = req.AltBody
camp.Messenger = req.Messenger
camp.ContentType = req.ContentType
camp.Headers = req.Headers
camp.TemplateID = req.TemplateID
for _, id := range req.MediaIDs {
if id > 0 {
camp.MediaIDs = append(camp.MediaIDs, int64(id))
}
}
// Send the test messages.
for _, s := range subs {
sub := s
if err := a.sendTestMessage(sub, &camp); err != nil {
a.log.Printf("error sending test message: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
a.i18n.Ts("campaigns.errorSendTest", "error", err.Error()))
}
}
return c.JSON(http.StatusOK, okResp{true})
}
// GetCampaignViewAnalytics retrieves view counts for a campaign.
func (a *App) GetCampaignViewAnalytics(c echo.Context) error {
ids, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
}
var (
typ = c.Param("type")
from = c.QueryParams().Get("from")
to = c.QueryParams().Get("to")
)
if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("analytics.invalidDates"))
}
// Campaign link stats.
if typ == "links" {
out, err := a.core.GetCampaignAnalyticsLinks(ids, typ, from, to)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// Get the analytics numbers from the DB for the campaigns.
out, err := a.core.GetCampaignAnalyticsCounts(ids, typ, from, to)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// sendTestMessage takes a campaign and a subscriber and sends out a sample campaign message.
func (a *App) sendTestMessage(sub models.Subscriber, camp *models.Campaign) error {
if err := camp.CompileTemplate(a.manager.TemplateFuncs(camp)); err != nil {
a.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
a.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Create a sample campaign message.
msg, err := a.manager.NewCampaignMessage(camp, sub)
if err != nil {
a.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusNotFound, a.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
return a.manager.PushCampaignMessage(msg)
}
// validateCampaignFields validates incoming campaign field values.
func (a *App) validateCampaignFields(c campReq) (campReq, error) {
if c.FromEmail == "" {
c.FromEmail = a.cfg.FromEmail
} else if !reFromAddress.Match([]byte(c.FromEmail)) {
if _, err := a.importer.SanitizeEmail(c.FromEmail); err != nil {
return c, errors.New(a.i18n.T("campaigns.fieldInvalidFromEmail"))
}
}
if !strHasLen(c.Name, 1, stdInputMaxLen) {
return c, errors.New(a.i18n.T("campaigns.fieldInvalidName"))
}
// Larger char limit for subject as it can contain {{ go templating }} logic.
if !strHasLen(c.Subject, 1, 5000) {
return c, errors.New(a.i18n.T("campaigns.fieldInvalidSubject"))
}
// If there's a "send_at" date, it should be in the future.
if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) {
return c, errors.New(a.i18n.T("campaigns.fieldInvalidSendAt"))
}
}
if len(c.ListIDs) == 0 {
return c, errors.New(a.i18n.T("campaigns.fieldInvalidListIDs"))
}
if !a.manager.HasMessenger(c.Messenger) {
return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger))
}
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
if err := c.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil {
return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
}
if len(c.Headers) == 0 {
c.Headers = make([]map[string]string, 0)
}
if len(c.ArchiveMeta) == 0 {
c.ArchiveMeta = json.RawMessage("{}")
}
if c.ArchiveSlug.String != "" {
// Format the slug to be alpha-numeric-dash.
s := strings.ToLower(c.ArchiveSlug.String)
s = strings.TrimSpace(reSlug.ReplaceAllString(s, " "))
s = regexpSpaces.ReplaceAllString(s, "-")
c.ArchiveSlug = null.NewString(s, true)
} else {
// If there's no slug set, set it to NULL in the DB.
c.ArchiveSlug.Valid = false
}
return c, nil
}
// makeOptinCampaignMessage makes a default opt-in campaign message body.
func (a *App) makeOptinCampaignMessage(o campReq) (campReq, error) {
if len(o.ListIDs) == 0 {
return o, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.fieldInvalidListIDs"))
}
// Fetch double opt-in lists from the given list IDs from the DB.
lists, err := a.core.GetListsByOptin(o.ListIDs, models.ListOptinDouble)
if err != nil {
return o, err
}
// There are no double opt-in lists.
if len(lists) == 0 {
return o, echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("campaigns.noOptinLists"))
}
// Construct the opt-in URL with list IDs.
listIDs := url.Values{}
for _, l := range lists {
listIDs.Add("l", l.UUID)
}
// optinURLFunc := template.URL("{{ OptinURL }}?" + listIDs.Encode())
optinURLAttr := template.HTMLAttr(fmt.Sprintf(`href="{{ OptinURL }}%s"`, listIDs.Encode()))
// Prepare sample opt-in message for the campaign.
var b bytes.Buffer
if err := notifs.Tpls.ExecuteTemplate(&b, "optin-campaign", struct {
Lists []models.List
OptinURLAttr template.HTMLAttr
}{lists, optinURLAttr}); err != nil {
a.log.Printf("error compiling 'optin-campaign' template: %v", err)
return o, echo.NewHTTPError(http.StatusBadRequest,
a.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
o.Body = b.String()
return o, nil
}
// checkCampaignPerm checks if the user has get or manage access to the given campaign.
func (a *App) checkCampaignPerm(types auth.PermType, id int, c echo.Context) error {
// Get the authenticated user.
user := auth.GetUser(c)
perm := auth.PermCampaignsGet
if types&auth.PermTypeGet != 0 {
// It's a get request and there's a blanket get all permission.
if user.HasPerm(auth.PermCampaignsGetAll) {
return nil
}
} else {
// It's a manage request and there's a blanket manage_all permission.
if user.HasPerm(auth.PermCampaignsManageAll) {
return nil
}
perm = auth.PermCampaignsManage
}
// There are no *_all campaign permissions. Instead, check if the user access
// blanket get_all/manage_all list permissions. If yes, then the user can access
// all campaigns. If there are no *_all permissions, then ensure that the
// campaign belongs to the lists that the user has access to.
if hasAllPerm, permittedListIDs := user.GetPermittedLists(auth.PermTypeGet | auth.PermTypeManage); !hasAllPerm {
if ok, err := a.core.CampaignHasLists(id, permittedListIDs); err != nil {
return err
} else if !ok {
return echo.NewHTTPError(http.StatusForbidden,
a.i18n.Ts("globals.messages.permissionDenied", "name", perm))
}
}
return nil
}
// canEditCampaign returns true if a campaign is in a status where updating
// its properties is allowed.
func canEditCampaign(status string) bool {
return status == models.CampaignStatusDraft ||
status == models.CampaignStatusPaused ||
status == models.CampaignStatusScheduled
}