2018-10-25 13:51:47 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2020-02-03 07:48:26 +00:00
|
|
|
"bytes"
|
2018-10-25 13:51:47 +00:00
|
|
|
"database/sql"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
2020-02-03 07:48:26 +00:00
|
|
|
"html/template"
|
2018-10-25 13:51:47 +00:00
|
|
|
"net/http"
|
2020-02-03 07:48:26 +00:00
|
|
|
"net/url"
|
2018-10-25 13:51:47 +00:00
|
|
|
"regexp"
|
|
|
|
"strconv"
|
2018-10-29 09:50:49 +00:00
|
|
|
"strings"
|
2018-10-25 13:51:47 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/asaskevich/govalidator"
|
2020-03-07 15:07:48 +00:00
|
|
|
"github.com/gofrs/uuid"
|
2018-10-25 13:51:47 +00:00
|
|
|
"github.com/knadh/listmonk/models"
|
|
|
|
"github.com/labstack/echo"
|
|
|
|
"github.com/lib/pq"
|
|
|
|
null "gopkg.in/volatiletech/null.v6"
|
|
|
|
)
|
|
|
|
|
|
|
|
// campaignReq is a wrapper over the Campaign model.
|
|
|
|
type campaignReq struct {
|
|
|
|
models.Campaign
|
2019-03-30 07:01:24 +00:00
|
|
|
|
2019-08-26 18:15:18 +00:00
|
|
|
// Indicates if the "send_at" date should be written or set to null.
|
|
|
|
SendLater bool `db:"-" json:"send_later"`
|
|
|
|
|
2019-03-30 07:01:24 +00:00
|
|
|
// 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"`
|
2018-10-29 09:50:49 +00:00
|
|
|
|
|
|
|
// This is only relevant to campaign test requests.
|
|
|
|
SubscriberEmails pq.StringArray `json:"subscribers"`
|
2020-02-03 07:48:26 +00:00
|
|
|
|
|
|
|
Type string `json:"type"`
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 float64 `json:"rate"`
|
|
|
|
}
|
|
|
|
|
2019-03-28 11:47:51 +00:00
|
|
|
type campsWrap struct {
|
2019-04-01 11:37:24 +00:00
|
|
|
Results models.Campaigns `json:"results"`
|
2019-03-28 11:47:51 +00:00
|
|
|
|
|
|
|
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+`)
|
|
|
|
)
|
2018-10-25 13:51:47 +00:00
|
|
|
|
|
|
|
// handleGetCampaigns handles retrieval of campaigns.
|
|
|
|
func handleGetCampaigns(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
pg = getPagination(c.QueryParams())
|
2019-03-28 11:47:51 +00:00
|
|
|
out campsWrap
|
2018-10-25 13:51:47 +00:00
|
|
|
|
|
|
|
id, _ = strconv.Atoi(c.Param("id"))
|
2019-03-28 11:47:51 +00:00
|
|
|
status = c.QueryParams()["status"]
|
|
|
|
query = strings.TrimSpace(c.FormValue("query"))
|
2018-10-25 13:51:47 +00:00
|
|
|
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
|
2019-03-28 11:47:51 +00:00
|
|
|
single = false
|
2018-10-25 13:51:47 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// Fetch one list.
|
|
|
|
if id > 0 {
|
|
|
|
single = true
|
|
|
|
}
|
2019-03-28 11:47:51 +00:00
|
|
|
if query != "" {
|
|
|
|
query = string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&")))
|
|
|
|
}
|
2018-10-25 13:51:47 +00:00
|
|
|
|
2019-03-30 07:01:24 +00:00
|
|
|
err := app.Queries.QueryCampaigns.Select(&out.Results, id, pq.StringArray(status), query, pg.Offset, pg.Limit)
|
2018-10-25 13:51:47 +00:00
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err)))
|
2019-10-25 05:41:47 +00:00
|
|
|
}
|
|
|
|
if single && len(out.Results) == 0 {
|
2018-10-25 13:51:47 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
2019-10-25 05:41:47 +00:00
|
|
|
}
|
|
|
|
if len(out.Results) == 0 {
|
2019-12-06 16:09:18 +00:00
|
|
|
out.Results = []models.Campaign{}
|
|
|
|
return c.JSON(http.StatusOK, okResp{out})
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2019-03-28 11:47:51 +00:00
|
|
|
for i := 0; i < len(out.Results); i++ {
|
2018-10-25 13:51:47 +00:00
|
|
|
// Replace null tags.
|
2019-03-28 11:47:51 +00:00
|
|
|
if out.Results[i].Tags == nil {
|
|
|
|
out.Results[i].Tags = make(pq.StringArray, 0)
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if noBody {
|
2019-03-28 11:47:51 +00:00
|
|
|
out.Results[i].Body = ""
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-04-01 11:37:24 +00:00
|
|
|
// Lazy load stats.
|
|
|
|
if err := out.Results.LoadStats(app.Queries.GetCampaignStats); err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign stats: %v", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
2018-10-25 13:51:47 +00:00
|
|
|
if single {
|
2019-03-28 11:47:51 +00:00
|
|
|
return c.JSON(http.StatusOK, okResp{out.Results[0]})
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2019-03-28 11:47:51 +00:00
|
|
|
// Meta.
|
|
|
|
out.Total = out.Results[0].Total
|
|
|
|
out.Page = pg.Page
|
|
|
|
out.PerPage = pg.PerPage
|
|
|
|
|
2018-10-25 13:51:47 +00:00
|
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
|
|
}
|
|
|
|
|
|
|
|
// handlePreviewTemplate 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"))
|
2018-10-26 05:48:17 +00:00
|
|
|
body = c.FormValue("body")
|
|
|
|
|
2018-10-31 12:54:21 +00:00
|
|
|
camp = &models.Campaign{}
|
2018-10-25 13:51:47 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
if id < 1 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
|
|
|
|
}
|
|
|
|
|
2018-10-31 12:54:21 +00:00
|
|
|
err := app.Queries.GetCampaignForPreview.Get(camp, id)
|
2018-10-25 13:51:47 +00:00
|
|
|
if err != nil {
|
2018-10-26 05:48:17 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
|
2018-10-25 13:51:47 +00:00
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
2018-10-26 05:48:17 +00:00
|
|
|
var sub models.Subscriber
|
2018-10-25 13:51:47 +00:00
|
|
|
// Get a random subscriber from the campaign.
|
|
|
|
if err := app.Queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
// There's no subscriber. Mock one.
|
2018-10-31 14:20:26 +00:00
|
|
|
sub = dummySubscriber
|
2018-10-25 13:51:47 +00:00
|
|
|
} else {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Compile the template.
|
2018-10-31 12:54:21 +00:00
|
|
|
if body != "" {
|
|
|
|
camp.Body = body
|
2018-10-26 05:48:17 +00:00
|
|
|
}
|
2018-10-31 12:54:21 +00:00
|
|
|
|
2018-12-19 06:33:13 +00:00
|
|
|
if err := camp.CompileTemplate(app.Manager.TemplateFuncs(camp)); err != nil {
|
2018-10-29 09:50:49 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
fmt.Sprintf("Error compiling template: %v", err))
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Render the message body.
|
2018-12-19 06:33:13 +00:00
|
|
|
m := app.Manager.NewMessage(camp, &sub)
|
2018-10-31 12:54:21 +00:00
|
|
|
if err := m.Render(); err != nil {
|
2018-10-29 09:50:49 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
2018-10-31 12:54:21 +00:00
|
|
|
fmt.Sprintf("Error rendering message: %v", err))
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2018-10-31 12:54:21 +00:00
|
|
|
return c.HTML(http.StatusOK, string(m.Body))
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2020-02-03 07:48:26 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2018-10-25 13:51:47 +00:00
|
|
|
// Validate.
|
2020-02-03 07:48:26 +00:00
|
|
|
if c, err := validateCampaignFields(o, app); err != nil {
|
2018-10-25 13:51:47 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
2020-02-03 07:48:26 +00:00
|
|
|
} else {
|
|
|
|
o = c
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2018-12-19 06:33:13 +00:00
|
|
|
if !app.Manager.HasMessenger(o.MessengerID) {
|
2018-10-25 13:51:47 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
fmt.Sprintf("Unknown messenger %s", o.MessengerID))
|
|
|
|
}
|
|
|
|
|
2020-03-07 15:07:48 +00:00
|
|
|
uu, err := uuid.NewV4()
|
|
|
|
if err != nil {
|
|
|
|
app.Logger.Println("error generating UUID: %v", err)
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID")
|
|
|
|
}
|
|
|
|
|
2018-10-25 13:51:47 +00:00
|
|
|
// Insert and read ID.
|
|
|
|
var newID int
|
|
|
|
if err := app.Queries.CreateCampaign.Get(&newID,
|
2020-03-07 15:07:48 +00:00
|
|
|
uu,
|
2020-02-03 07:48:26 +00:00
|
|
|
o.Type,
|
2018-10-25 13:51:47 +00:00
|
|
|
o.Name,
|
|
|
|
o.Subject,
|
|
|
|
o.FromEmail,
|
|
|
|
o.Body,
|
|
|
|
o.ContentType,
|
|
|
|
o.SendAt,
|
|
|
|
pq.StringArray(normalizeTags(o.Tags)),
|
|
|
|
"email",
|
|
|
|
o.TemplateID,
|
2019-03-30 07:01:24 +00:00
|
|
|
o.ListIDs,
|
2018-10-25 13:51:47 +00:00
|
|
|
); err != nil {
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
"There aren't any subscribers in the target lists to create the campaign.")
|
|
|
|
}
|
|
|
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error creating campaign: %v", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Hand over to the GET handler to return the last insertion.
|
|
|
|
c.SetParamNames("id")
|
|
|
|
c.SetParamValues(fmt.Sprintf("%d", newID))
|
|
|
|
return handleGetCampaigns(c)
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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, "Invalid ID.")
|
|
|
|
}
|
|
|
|
|
|
|
|
var cm models.Campaign
|
2019-03-30 07:01:24 +00:00
|
|
|
if err := app.Queries.GetCampaign.Get(&cm, id); err != nil {
|
2018-10-25 13:51:47 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
|
|
|
if isCampaignalMutable(cm.Status) {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
"Cannot update a running or a finished campaign.")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Incoming params.
|
|
|
|
var o campaignReq
|
|
|
|
if err := c.Bind(&o); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2020-02-03 07:48:26 +00:00
|
|
|
if c, err := validateCampaignFields(o, app); err != nil {
|
2018-10-25 13:51:47 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
2020-02-03 07:48:26 +00:00
|
|
|
} else {
|
|
|
|
o = c
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
|
|
|
|
o.Name,
|
|
|
|
o.Subject,
|
|
|
|
o.FromEmail,
|
|
|
|
o.Body,
|
|
|
|
o.ContentType,
|
|
|
|
o.SendAt,
|
2019-08-26 18:15:18 +00:00
|
|
|
o.SendLater,
|
2018-10-25 13:51:47 +00:00
|
|
|
pq.StringArray(normalizeTags(o.Tags)),
|
|
|
|
o.TemplateID,
|
2019-03-30 07:01:24 +00:00
|
|
|
o.ListIDs)
|
2018-10-25 13:51:47 +00:00
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
|
|
|
|
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, "Invalid ID.")
|
|
|
|
}
|
|
|
|
|
|
|
|
var cm models.Campaign
|
2019-03-30 07:01:24 +00:00
|
|
|
if err := app.Queries.GetCampaign.Get(&cm, id); err != nil {
|
2018-10-25 13:51:47 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign: %s", 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 = "Only scheduled campaigns can be saved as drafts"
|
|
|
|
}
|
|
|
|
case models.CampaignStatusScheduled:
|
|
|
|
if cm.Status != models.CampaignStatusDraft {
|
|
|
|
errMsg = "Only draft campaigns can be scheduled"
|
|
|
|
}
|
|
|
|
if !cm.SendAt.Valid {
|
|
|
|
errMsg = "Campaign needs a `send_at` date to be scheduled"
|
|
|
|
}
|
|
|
|
|
|
|
|
case models.CampaignStatusRunning:
|
|
|
|
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
|
|
|
|
errMsg = "Only paused campaigns and drafts can be started"
|
|
|
|
}
|
|
|
|
case models.CampaignStatusPaused:
|
|
|
|
if cm.Status != models.CampaignStatusRunning {
|
|
|
|
errMsg = "Only active campaigns can be paused"
|
|
|
|
}
|
|
|
|
case models.CampaignStatusCancelled:
|
|
|
|
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
|
|
|
|
errMsg = "Only active campaigns can be cancelled"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(errMsg) > 0 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, errMsg)
|
|
|
|
}
|
|
|
|
|
|
|
|
res, err := app.Queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
|
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
|
|
|
|
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, "Invalid ID.")
|
|
|
|
}
|
|
|
|
|
|
|
|
var cm models.Campaign
|
2019-03-30 07:01:24 +00:00
|
|
|
if err := app.Queries.GetCampaign.Get(&cm, id); err != nil {
|
2018-10-25 13:51:47 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Only scheduled campaigns can be deleted.
|
|
|
|
if cm.Status != models.CampaignStatusDraft &&
|
|
|
|
cm.Status != models.CampaignStatusScheduled {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
"Only campaigns that haven't been started can be deleted.")
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := app.Queries.DeleteCampaign.Exec(cm.ID); err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error deleting campaign: %v", 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
|
|
|
|
)
|
|
|
|
|
2019-04-01 11:37:24 +00:00
|
|
|
if err := app.Queries.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
|
2018-10-25 13:51:47 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
|
|
|
|
}
|
|
|
|
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign stats: %s", 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 := c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()
|
|
|
|
if diff > 0 {
|
2018-11-27 06:14:54 +00:00
|
|
|
var (
|
|
|
|
sent = float64(c.Sent)
|
|
|
|
rate = sent / diff
|
|
|
|
)
|
|
|
|
if rate > sent || rate > float64(c.ToSend) {
|
|
|
|
rate = sent
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
2018-11-27 06:14:54 +00:00
|
|
|
out[i].Rate = rate
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, okResp{out})
|
|
|
|
}
|
|
|
|
|
2018-10-29 09:50:49 +00:00
|
|
|
// 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"))
|
|
|
|
req campaignReq
|
|
|
|
)
|
|
|
|
|
|
|
|
if campID < 1 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid campaign ID.")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get and validate fields.
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
// Validate.
|
2020-02-03 07:48:26 +00:00
|
|
|
if c, err := validateCampaignFields(req, app); err != nil {
|
2018-10-29 09:50:49 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
2020-02-03 07:48:26 +00:00
|
|
|
} else {
|
|
|
|
req = c
|
2018-10-29 09:50:49 +00:00
|
|
|
}
|
|
|
|
if len(req.SubscriberEmails) == 0 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching subscribers: %s", pqErrMsg(err)))
|
|
|
|
} else if len(subs) == 0 {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "No known subscribers given.")
|
|
|
|
}
|
|
|
|
|
|
|
|
// The campaign.
|
|
|
|
var camp models.Campaign
|
|
|
|
if err := app.Queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
|
|
|
}
|
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Override certain values in the DB with incoming values.
|
|
|
|
camp.Name = req.Name
|
|
|
|
camp.Subject = req.Subject
|
|
|
|
camp.FromEmail = req.FromEmail
|
|
|
|
camp.Body = req.Body
|
|
|
|
|
|
|
|
// Send the test messages.
|
|
|
|
for _, s := range subs {
|
2019-10-25 05:41:47 +00:00
|
|
|
sub := s
|
|
|
|
if err := sendTestMessage(&sub, &camp, app); err != nil {
|
2018-10-29 09:50:49 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error sending test: %v", err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, okResp{true})
|
|
|
|
}
|
|
|
|
|
2019-10-25 05:41:47 +00:00
|
|
|
// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
|
2018-10-29 09:50:49 +00:00
|
|
|
func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) error {
|
2018-12-19 06:33:13 +00:00
|
|
|
if err := camp.CompileTemplate(app.Manager.TemplateFuncs(camp)); err != nil {
|
2018-10-29 09:50:49 +00:00
|
|
|
return fmt.Errorf("Error compiling template: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Render the message body.
|
2018-12-19 06:33:13 +00:00
|
|
|
m := app.Manager.NewMessage(camp, sub)
|
2018-11-06 03:22:20 +00:00
|
|
|
if err := m.Render(); err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
fmt.Sprintf("Error rendering message: %v", err))
|
2018-10-29 09:50:49 +00:00
|
|
|
}
|
|
|
|
|
2019-07-18 07:10:48 +00:00
|
|
|
if err := app.Messenger.Push(camp.FromEmail, []string{sub.Email}, camp.Subject, m.Body, nil); err != nil {
|
2018-10-29 09:50:49 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2018-10-25 13:51:47 +00:00
|
|
|
// validateCampaignFields validates incoming campaign field values.
|
2020-02-03 07:48:26 +00:00
|
|
|
func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
|
|
|
|
if c.FromEmail == "" {
|
|
|
|
c.FromEmail = app.Constants.FromEmail
|
|
|
|
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
|
2018-10-25 13:51:47 +00:00
|
|
|
if !govalidator.IsEmail(c.FromEmail) {
|
2020-02-03 07:48:26 +00:00
|
|
|
return c, errors.New("invalid `from_email`")
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) {
|
2020-02-03 07:48:26 +00:00
|
|
|
return c, errors.New("invalid length for `name`")
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) {
|
2020-02-03 07:48:26 +00:00
|
|
|
return c, errors.New("invalid length for `subject`")
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) {
|
2020-02-03 07:48:26 +00:00
|
|
|
// return c,errors.New("invalid length for `body`")
|
2018-10-25 13:51:47 +00:00
|
|
|
// }
|
|
|
|
|
|
|
|
// If there's a "send_at" date, it should be in the future.
|
|
|
|
if c.SendAt.Valid {
|
|
|
|
if c.SendAt.Time.Before(time.Now()) {
|
2020-02-03 07:48:26 +00:00
|
|
|
return c, errors.New("`send_at` date should be in the future")
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-31 14:12:07 +00:00
|
|
|
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
|
2018-12-19 06:33:13 +00:00
|
|
|
if err := c.CompileTemplate(app.Manager.TemplateFuncs(&camp)); err != nil {
|
2020-02-03 07:48:26 +00:00
|
|
|
return c, fmt.Errorf("Error compiling campaign body: %v", err)
|
2018-10-29 09:50:49 +00:00
|
|
|
}
|
|
|
|
|
2020-02-03 07:48:26 +00:00
|
|
|
return c, nil
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
2020-02-03 07:48:26 +00:00
|
|
|
|
|
|
|
// 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, "Invalid list IDs.")
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 {
|
2020-02-09 10:55:19 +00:00
|
|
|
app.Logger.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
|
2020-02-03 07:48:26 +00:00
|
|
|
return o, echo.NewHTTPError(http.StatusInternalServerError,
|
2020-02-09 10:55:19 +00:00
|
|
|
"Error fetching opt-in lists.")
|
2020-02-03 07:48:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// No opt-in lists.
|
|
|
|
if len(lists) == 0 {
|
|
|
|
return o, echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
"No opt-in lists found to create campaign.")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Construct the opt-in URL with list IDs.
|
|
|
|
var (
|
|
|
|
listIDs = url.Values{}
|
|
|
|
listNames = make([]string, 0, len(lists))
|
|
|
|
)
|
|
|
|
for _, l := range lists {
|
|
|
|
listIDs.Add("l", l.UUID)
|
|
|
|
listNames = append(listNames, l.Name)
|
|
|
|
}
|
|
|
|
// 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.ExecuteTemplate(&b, "optin-campaign", struct {
|
|
|
|
Lists []models.List
|
|
|
|
OptinURLAttr template.HTMLAttr
|
|
|
|
}{lists, optinURLAttr}); err != nil {
|
|
|
|
app.Logger.Printf("error compiling 'optin-campaign' template: %v", err)
|
|
|
|
return o, echo.NewHTTPError(http.StatusInternalServerError,
|
|
|
|
"Error compiling opt-in campaign template.")
|
|
|
|
}
|
|
|
|
|
|
|
|
o.Name = "Opt-in campaign " + strings.Join(listNames, ", ")
|
|
|
|
o.Subject = "Confirm your subscription(s)"
|
|
|
|
o.Body = b.String()
|
|
|
|
return o, nil
|
|
|
|
}
|