package main import ( "bytes" "database/sql" "errors" "fmt" "net/http" "regexp" "strconv" "time" "github.com/asaskevich/govalidator" "github.com/knadh/listmonk/models" "github.com/knadh/listmonk/runner" "github.com/labstack/echo" "github.com/lib/pq" uuid "github.com/satori/go.uuid" null "gopkg.in/volatiletech/null.v6" ) // campaignReq is a wrapper over the Campaign model. type campaignReq struct { models.Campaign MessengerID string `json:"messenger"` Lists pq.Int64Array `json:"lists"` } 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"` } var regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`) // handleGetCampaigns handles retrieval of campaigns. func handleGetCampaigns(c echo.Context) error { var ( app = c.Get("app").(*App) pg = getPagination(c.QueryParams()) out models.Campaigns id, _ = strconv.Atoi(c.Param("id")) status = c.FormValue("status") single = false noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) ) // Fetch one list. if id > 0 { single = true } err := app.Queries.GetCampaigns.Select(&out, id, status, pg.Offset, pg.Limit) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err))) } else if single && len(out) == 0 { return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") } else if len(out) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } for i := 0; i < len(out); i++ { // Replace null tags. if out[i].Tags == nil { out[i].Tags = make(pq.StringArray, 0) } if noBody { out[i].Body = "" } } if single { return c.JSON(http.StatusOK, okResp{out[0]}) } 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")) camps models.Campaigns ) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") } err := app.Queries.GetCampaigns.Select(&camps, id, "", 0, 1) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) } else if len(camps) == 0 { return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") } var ( camp = camps[0] sub models.Subscriber ) // 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. sub = models.Subscriber{ Name: "Dummy Subscriber", Email: "dummy@subscriber.com", UUID: "00000000-0000-0000-0000-000000000000", Status: models.SubscriberStatusEnabled, } } else { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err))) } } // Compile the template. tpl, err := runner.CompileMessageTemplate(`{{ template "content" . }}`, camp.Body) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err)) } // Render the message body. var out = bytes.Buffer{} if err := tpl.ExecuteTemplate(&out, runner.BaseTPL, runner.Message{Campaign: &camp, Subscriber: &sub, UnsubscribeURL: "#dummy"}); err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error executing template: %v", err)) } return c.HTML(http.StatusOK, out.String()) } // 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 } // Validate. if err := validateCampaignFields(o); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if !app.Runner.HasMessenger(o.MessengerID) { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unknown messenger %s", o.MessengerID)) } // Insert and read ID. var newID int if err := app.Queries.CreateCampaign.Get(&newID, uuid.NewV4(), o.Name, o.Subject, o.FromEmail, o.Body, o.ContentType, o.SendAt, pq.StringArray(normalizeTags(o.Tags)), "email", o.TemplateID, o.Lists, ); 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 if err := app.Queries.GetCampaigns.Get(&cm, id, "", 0, 1); 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))) } 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 } if err := validateCampaignFields(o); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } res, err := app.Queries.UpdateCampaign.Exec(cm.ID, o.Name, o.Subject, o.FromEmail, o.Body, o.ContentType, o.SendAt, pq.StringArray(normalizeTags(o.Tags)), o.TemplateID, o.Lists) 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 if err := app.Queries.GetCampaigns.Get(&cm, id, "", 0, 1); 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))) } // 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 if err := app.Queries.GetCampaigns.Get(&cm, id, "", 0, 1); 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))) } // 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 ) if err := app.Queries.GetCampaignStats.Select(&out, models.CampaignStatusRunning); err != nil { 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 { out[i].Rate = float64(c.Sent) / diff t := float64(c.ToSend) if out[i].Rate > t { out[i].Rate = t } } } } return c.JSON(http.StatusOK, okResp{out}) } // handleGetCampaignMessengers returns the list of registered messengers. func handleGetCampaignMessengers(c echo.Context) error { app := c.Get("app").(*App) return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()}) } // validateCampaignFields validates incoming campaign field values. func validateCampaignFields(c campaignReq) error { if !regexFromAddress.Match([]byte(c.FromEmail)) { if !govalidator.IsEmail(c.FromEmail) { return errors.New("invalid `from_email`") } } if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) { return errors.New("invalid length for `name`") } if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) { return errors.New("invalid length for `subject`") } // if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) { // return 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 errors.New("`send_at` date should be in the future") } } return 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 }