mirror of
https://github.com/knadh/listmonk.git
synced 2025-01-10 00:08:15 +08:00
0f6a0376da
The `rate` field `/api/campaigns/running/stats` returned was computed based on the total time spent from the start of the campaign to the current time. This meant that for large campaigns, if there were pauses or slowdowns in between, the rate would be skewed heavily making it useless to figure out the current send rate. This commit introduces a realtime running rate counter in the campaign manager that returns accurate (running) send rates for the last minute. The `rate` field in the API now shows the live running rate and a new `net_rate` field shows the rate from the beginning of the campaign.
823 lines
24 KiB
Go
823 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/knadh/listmonk/models"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/lib/pq"
|
|
null "gopkg.in/volatiletech/null.v6"
|
|
)
|
|
|
|
// campaignReq is a wrapper over the Campaign model for receiving
|
|
// campaign creation and updation data from APIs.
|
|
type campaignReq struct {
|
|
models.Campaign
|
|
|
|
// Indicates if the "send_at" date should be written or set to null.
|
|
SendLater bool `db:"-" json:"send_later"`
|
|
|
|
// 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 pq.Int64Array `db:"-" json:"lists"`
|
|
|
|
// This is only relevant to campaign test requests.
|
|
SubscriberEmails pq.StringArray `json:"subscribers"`
|
|
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// campaignContentReq wraps params coming from API requests for converting
|
|
// campaign content formats.
|
|
type campaignContentReq struct {
|
|
models.Campaign
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
|
|
type campCountStats struct {
|
|
CampaignID int `db:"campaign_id" json:"campaign_id"`
|
|
Count int `db:"count" json:"count"`
|
|
Timestamp time.Time `db:"timestamp" json:"timestamp"`
|
|
}
|
|
|
|
type campTopLinks struct {
|
|
URL string `db:"url" json:"url"`
|
|
Count int `db:"count" json:"count"`
|
|
}
|
|
|
|
type campaignStats struct {
|
|
ID int `db:"id" json:"id"`
|
|
Status string `db:"status" json:"status"`
|
|
ToSend int `db:"to_send" json:"to_send"`
|
|
Sent int `db:"sent" json:"sent"`
|
|
Started null.Time `db:"started_at" json:"started_at"`
|
|
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
|
|
Rate int `json:"rate"`
|
|
NetRate int `json:"net_rate"`
|
|
}
|
|
|
|
type campsWrap struct {
|
|
Results models.Campaigns `json:"results"`
|
|
|
|
Query string `json:"query"`
|
|
Total int `json:"total"`
|
|
PerPage int `json:"per_page"`
|
|
Page int `json:"page"`
|
|
}
|
|
|
|
var (
|
|
regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
|
|
regexFullTextQuery = regexp.MustCompile(`\s+`)
|
|
|
|
campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
|
|
bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"}
|
|
)
|
|
|
|
// handleGetCampaigns handles retrieval of campaigns.
|
|
func handleGetCampaigns(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
pg = getPagination(c.QueryParams(), 20)
|
|
out campsWrap
|
|
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
|
status = c.QueryParams()["status"]
|
|
query = strings.TrimSpace(c.FormValue("query"))
|
|
orderBy = c.FormValue("order_by")
|
|
order = c.FormValue("order")
|
|
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
|
|
)
|
|
|
|
// Fetch one campaign.
|
|
single := false
|
|
if id > 0 {
|
|
single = true
|
|
}
|
|
|
|
queryStr, stmt := makeSearchQuery(query, orderBy, order, app.queries.QueryCampaigns)
|
|
|
|
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
|
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), queryStr, pg.Offset, pg.Limit); err != nil {
|
|
app.log.Printf("error fetching campaigns: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
if single && len(out.Results) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("campaigns.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
if len(out.Results) == 0 {
|
|
out.Results = []models.Campaign{}
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
}
|
|
|
|
for i := 0; i < len(out.Results); i++ {
|
|
// Replace null tags.
|
|
if out.Results[i].Tags == nil {
|
|
out.Results[i].Tags = make(pq.StringArray, 0)
|
|
}
|
|
|
|
if noBody {
|
|
out.Results[i].Body = ""
|
|
}
|
|
}
|
|
|
|
// Lazy load stats.
|
|
if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
|
|
app.log.Printf("error fetching campaign stats: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
if single {
|
|
return c.JSON(http.StatusOK, okResp{out.Results[0]})
|
|
}
|
|
|
|
// Meta.
|
|
out.Total = out.Results[0].Total
|
|
out.Page = pg.Page
|
|
out.PerPage = pg.PerPage
|
|
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
}
|
|
|
|
// handlePreviewCampaign renders the HTML preview of a campaign body.
|
|
func handlePreviewCampaign(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
|
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
|
|
)
|
|
|
|
if id < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
|
}
|
|
|
|
var camp models.Campaign
|
|
if err := app.queries.GetCampaignForPreview.Get(&camp, id, tplID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
app.log.Printf("error fetching campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
// There's a body in the request to preview instead of the body in the DB.
|
|
if c.Request().Method == http.MethodPost {
|
|
camp.ContentType = c.FormValue("content_type")
|
|
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(app.manager.TemplateFuncs(&camp)); err != nil {
|
|
app.log.Printf("error compiling template: %v", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
|
|
}
|
|
|
|
// Render the message body.
|
|
msg, err := app.manager.NewCampaignMessage(&camp, dummySubscriber)
|
|
if err != nil {
|
|
app.log.Printf("error rendering message: %v", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.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()))
|
|
}
|
|
|
|
// handleCampaignContent handles campaign content (body) format conversions.
|
|
func handleCampaignContent(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
|
)
|
|
|
|
if id < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
|
}
|
|
|
|
var camp campaignContentReq
|
|
if err := c.Bind(&camp); err != nil {
|
|
return err
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// handleCreateCampaign handles campaign creation.
|
|
// Newly created campaigns are always drafts.
|
|
func handleCreateCampaign(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
o campaignReq
|
|
)
|
|
|
|
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 := makeOptinCampaignMessage(o, app)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o = op
|
|
} else if o.Type == "" {
|
|
o.Type = models.CampaignTypeRegular
|
|
}
|
|
|
|
if o.ContentType == "" {
|
|
o.ContentType = models.CampaignContentTypeRichtext
|
|
}
|
|
if o.Messenger == "" {
|
|
o.Messenger = "email"
|
|
}
|
|
|
|
// Validate.
|
|
if c, err := validateCampaignFields(o, app); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
} else {
|
|
o = c
|
|
}
|
|
|
|
uu, err := uuid.NewV4()
|
|
if err != nil {
|
|
app.log.Printf("error generating UUID: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
|
}
|
|
|
|
// Insert and read ID.
|
|
var newID int
|
|
if err := app.queries.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,
|
|
o.ListIDs,
|
|
); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs"))
|
|
}
|
|
|
|
app.log.Printf("error creating campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorCreating",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
// Hand over to the GET handler to return the last insertion.
|
|
return handleGetCampaigns(copyEchoCtx(c, map[string]string{
|
|
"id": fmt.Sprintf("%d", newID),
|
|
}))
|
|
}
|
|
|
|
// handleUpdateCampaign handles campaign modification.
|
|
// Campaigns that are done cannot be modified.
|
|
func handleUpdateCampaign(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
|
)
|
|
|
|
if id < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
|
|
|
}
|
|
|
|
var cm models.Campaign
|
|
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
app.log.Printf("error fetching campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
if isCampaignalMutable(cm.Status) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.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 where as fields
|
|
// that are not in the request retain the old values.
|
|
o := campaignReq{Campaign: cm}
|
|
if err := c.Bind(&o); err != nil {
|
|
return err
|
|
}
|
|
|
|
if c, err := validateCampaignFields(o, app); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
} else {
|
|
o = c
|
|
}
|
|
|
|
_, err := app.queries.UpdateCampaign.Exec(cm.ID,
|
|
o.Name,
|
|
o.Subject,
|
|
o.FromEmail,
|
|
o.Body,
|
|
o.AltBody,
|
|
o.ContentType,
|
|
o.SendAt,
|
|
o.SendLater,
|
|
o.Headers,
|
|
pq.StringArray(normalizeTags(o.Tags)),
|
|
o.Messenger,
|
|
o.TemplateID,
|
|
o.ListIDs)
|
|
if err != nil {
|
|
app.log.Printf("error updating campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorUpdating",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return handleGetCampaigns(c)
|
|
}
|
|
|
|
// handleUpdateCampaignStatus handles campaign status modification.
|
|
func handleUpdateCampaignStatus(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
|
)
|
|
|
|
if id < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
|
}
|
|
|
|
var cm models.Campaign
|
|
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
app.log.Printf("error fetching campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
// Incoming params.
|
|
var o campaignReq
|
|
if err := c.Bind(&o); err != nil {
|
|
return err
|
|
}
|
|
|
|
errMsg := ""
|
|
switch o.Status {
|
|
case models.CampaignStatusDraft:
|
|
if cm.Status != models.CampaignStatusScheduled {
|
|
errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
|
|
}
|
|
case models.CampaignStatusScheduled:
|
|
if cm.Status != models.CampaignStatusDraft {
|
|
errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
|
|
}
|
|
if !cm.SendAt.Valid {
|
|
errMsg = app.i18n.T("campaigns.needsSendAt")
|
|
}
|
|
|
|
case models.CampaignStatusRunning:
|
|
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
|
|
errMsg = app.i18n.T("campaigns.onlyPausedDraft")
|
|
}
|
|
case models.CampaignStatusPaused:
|
|
if cm.Status != models.CampaignStatusRunning {
|
|
errMsg = app.i18n.T("campaigns.onlyActivePause")
|
|
}
|
|
case models.CampaignStatusCancelled:
|
|
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
|
|
errMsg = app.i18n.T("campaigns.onlyActiveCancel")
|
|
}
|
|
}
|
|
|
|
if len(errMsg) > 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, errMsg)
|
|
}
|
|
|
|
res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
|
|
if err != nil {
|
|
app.log.Printf("error updating campaign status: %v", err)
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorUpdating",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.notFound",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return handleGetCampaigns(c)
|
|
}
|
|
|
|
// handleDeleteCampaign handles campaign deletion.
|
|
// Only scheduled campaigns that have not started yet can be deleted.
|
|
func handleDeleteCampaign(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
|
)
|
|
|
|
if id < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
|
}
|
|
|
|
var cm models.Campaign
|
|
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.notFound",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
app.log.Printf("error fetching campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil {
|
|
app.log.Printf("error deleting campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorDeleting",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, okResp{true})
|
|
}
|
|
|
|
// handleGetRunningCampaignStats returns stats of a given set of campaign IDs.
|
|
func handleGetRunningCampaignStats(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
out []campaignStats
|
|
)
|
|
|
|
if err := app.queries.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
|
|
}
|
|
|
|
app.log.Printf("error fetching campaign stats: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
|
} else 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 := int(c.UpdatedAt.Time.Sub(c.Started.Time).Minutes())
|
|
if diff < 1 {
|
|
diff = 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 = app.manager.GetCampaignStats(c.ID).SendRate
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
}
|
|
|
|
// handleTestCampaign handles the sending of a campaign message to
|
|
// arbitrary subscribers for testing.
|
|
func handleTestCampaign(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
campID, _ = strconv.Atoi(c.Param("id"))
|
|
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
|
|
req campaignReq
|
|
)
|
|
|
|
if campID < 1 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
|
|
}
|
|
|
|
// Get and validate fields.
|
|
if err := c.Bind(&req); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate.
|
|
if c, err := validateCampaignFields(req, app); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
} else {
|
|
req = c
|
|
}
|
|
if len(req.SubscriberEmails) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubsToTest"))
|
|
}
|
|
|
|
// Get the subscribers.
|
|
for i := 0; i < len(req.SubscriberEmails); i++ {
|
|
req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i]))
|
|
}
|
|
var subs models.Subscribers
|
|
if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
|
|
app.log.Printf("error fetching subscribers: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
|
} else if len(subs) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest"))
|
|
}
|
|
|
|
// The campaign.
|
|
var camp models.Campaign
|
|
if err := app.queries.GetCampaignForPreview.Get(&camp, campID, tplID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.notFound",
|
|
"name", "{globals.terms.campaign}"))
|
|
}
|
|
|
|
app.log.Printf("error fetching campaign: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.campaign}", "error", pqErrMsg(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
|
|
|
|
// Send the test messages.
|
|
for _, s := range subs {
|
|
sub := s
|
|
if err := sendTestMessage(sub, &camp, app); err != nil {
|
|
app.log.Printf("error sending test message: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("campaigns.errorSendTest", "error", err.Error()))
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, okResp{true})
|
|
}
|
|
|
|
// handleGetCampaignViewAnalytics retrieves view counts for a campaign.
|
|
func handleGetCampaignViewAnalytics(c echo.Context) error {
|
|
var (
|
|
app = c.Get("app").(*App)
|
|
|
|
typ = c.Param("type")
|
|
from = c.QueryParams().Get("from")
|
|
to = c.QueryParams().Get("to")
|
|
)
|
|
|
|
ids, err := parseStringIDs(c.Request().URL.Query()["id"])
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
|
|
}
|
|
|
|
// Pick campaign view counts or click counts.
|
|
var stmt *sqlx.Stmt
|
|
switch typ {
|
|
case "views":
|
|
stmt = app.queries.GetCampaignViewCounts
|
|
case "clicks":
|
|
stmt = app.queries.GetCampaignClickCounts
|
|
case "bounces":
|
|
stmt = app.queries.GetCampaignBounceCounts
|
|
case "links":
|
|
out := make([]campTopLinks, 0)
|
|
if err := app.queries.GetCampaignLinkCounts.Select(&out, pq.Int64Array(ids), from, to); err != nil {
|
|
app.log.Printf("error fetching campaign %s: %v", typ, err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
|
}
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
default:
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
|
}
|
|
|
|
if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
|
|
}
|
|
|
|
out := make([]campCountStats, 0)
|
|
if err := stmt.Select(&out, pq.Int64Array(ids), from, to); err != nil {
|
|
app.log.Printf("error fetching campaign %s: %v", typ, err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
}
|
|
|
|
// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
|
|
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
|
|
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
|
|
app.log.Printf("error compiling template: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
|
|
}
|
|
|
|
// Create a sample campaign message.
|
|
msg, err := app.manager.NewCampaignMessage(camp, sub)
|
|
if err != nil {
|
|
app.log.Printf("error rendering message: %v", err)
|
|
return echo.NewHTTPError(http.StatusNotFound,
|
|
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
|
|
}
|
|
|
|
return app.manager.PushCampaignMessage(msg)
|
|
}
|
|
|
|
// validateCampaignFields validates incoming campaign field values.
|
|
func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
|
|
if c.FromEmail == "" {
|
|
c.FromEmail = app.constants.FromEmail
|
|
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
|
|
if _, err := app.importer.SanitizeEmail(c.FromEmail); err != nil {
|
|
return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail"))
|
|
}
|
|
}
|
|
|
|
if !strHasLen(c.Name, 1, stdInputMaxLen) {
|
|
return c, errors.New(app.i18n.T("campaigns.fieldInvalidName"))
|
|
}
|
|
if !strHasLen(c.Subject, 1, stdInputMaxLen) {
|
|
return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject"))
|
|
}
|
|
|
|
// if !hasLen(c.Body, 1, bodyMaxLen) {
|
|
// return c,errors.New("invalid length for `body`")
|
|
// }
|
|
|
|
// 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(app.i18n.T("campaigns.fieldInvalidSendAt"))
|
|
}
|
|
}
|
|
|
|
if len(c.ListIDs) == 0 {
|
|
return c, errors.New(app.i18n.T("campaigns.fieldInvalidListIDs"))
|
|
}
|
|
|
|
if !app.manager.HasMessenger(c.Messenger) {
|
|
return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger))
|
|
}
|
|
|
|
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
|
|
if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
|
|
return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
|
|
}
|
|
|
|
if len(c.Headers) == 0 {
|
|
c.Headers = make([]map[string]string, 0)
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// isCampaignalMutable tells if a campaign's in a state where it's
|
|
// properties can be mutated.
|
|
func isCampaignalMutable(status string) bool {
|
|
return status == models.CampaignStatusRunning ||
|
|
status == models.CampaignStatusCancelled ||
|
|
status == models.CampaignStatusFinished
|
|
}
|
|
|
|
// makeOptinCampaignMessage makes a default opt-in campaign message body.
|
|
func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
|
|
if len(o.ListIDs) == 0 {
|
|
return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.fieldInvalidListIDs"))
|
|
}
|
|
|
|
// Fetch double opt-in lists from the given list IDs.
|
|
var lists []models.List
|
|
err := app.queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil)
|
|
if err != nil {
|
|
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
|
|
return o, echo.NewHTTPError(http.StatusInternalServerError,
|
|
app.i18n.Ts("globals.messages.errorFetching",
|
|
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
|
}
|
|
|
|
// No opt-in lists.
|
|
if len(lists) == 0 {
|
|
return o, echo.NewHTTPError(http.StatusBadRequest, app.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 := app.notifTpls.tpls.ExecuteTemplate(&b, "optin-campaign", struct {
|
|
Lists []models.List
|
|
OptinURLAttr template.HTMLAttr
|
|
}{lists, optinURLAttr}); err != nil {
|
|
app.log.Printf("error compiling 'optin-campaign' template: %v", err)
|
|
return o, echo.NewHTTPError(http.StatusBadRequest,
|
|
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
|
|
}
|
|
|
|
o.Body = b.String()
|
|
return o, nil
|
|
}
|
|
|
|
// makeSearchQuery cleans an optional search string and prepares the
|
|
// query SQL statement (string interpolated) and returns the
|
|
// search query string along with the SQL expression.
|
|
func makeSearchQuery(q, orderBy, order, query string) (string, string) {
|
|
if q != "" {
|
|
q = `%` + string(regexFullTextQuery.ReplaceAll([]byte(q), []byte("&"))) + `%`
|
|
}
|
|
|
|
// Sort params.
|
|
if !strSliceContains(orderBy, campaignQuerySortFields) {
|
|
orderBy = "created_at"
|
|
}
|
|
if order != sortAsc && order != sortDesc {
|
|
order = sortDesc
|
|
}
|
|
|
|
return q, fmt.Sprintf(query, orderBy, order)
|
|
}
|