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 = "" res[i].BodySource.Valid = false } } // 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, template ID for previewing is irrelevant. if contentType == models.CampaignContentTypeVisual || tplID < 1 { tplID = 0 } // Get the campaign from the DB for previewing with the `template_body` field. 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") // For visual campaigns, template body from the DB shouldn't be used. if contentType == models.CampaignContentTypeVisual { camp.TemplateBody = "" } } // 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())) } // Plaintext headers for plain body. if camp.ContentType == models.CampaignContentTypePlain { return c.String(http.StatusOK, string(msg.Body())) } return c.HTML(http.StatusOK, string(msg.Body())) } // PreviewCampaignArchive renders the public campaign archives page. func (a *App) PreviewCampaignArchive(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 } // Fetch the campaign body from the DB. tplID, _ := strconv.Atoi(c.FormValue("template_id")) camp, err := a.core.GetCampaignForPreview(id, tplID) if err != nil { return err } camp.ArchiveMeta = json.RawMessage([]byte(c.FormValue("archive_meta"))) // "Compile" the campaign template with appropriate data. res, err := a.compileArchiveCampaigns([]models.Campaign{camp}) if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign"))) } // Render the campaign body. out := res[0].Campaign msg, err := a.manager.NewCampaignMessage(out, res[0].Subscriber) if err != nil { a.log.Printf("error rendering campaign: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign"))) } 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 no content-type is specified, default to richtext. if c.ContentType != models.CampaignContentTypeRichtext && c.ContentType != models.CampaignContentTypeHTML && c.ContentType != models.CampaignContentTypePlain && c.ContentType != models.CampaignContentTypeVisual && c.ContentType != models.CampaignContentTypeMarkdown && c.ContentType != models.CampaignContentTypeMJML { c.ContentType = models.CampaignContentTypeRichtext } if c.ContentType != models.CampaignContentTypeVisual { c.BodySource.Valid = false } // 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 }