diff --git a/cmd/admin.go b/cmd/admin.go index 2736ff9..abaf6d0 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -7,7 +7,6 @@ import ( "syscall" "time" - "github.com/jmoiron/sqlx/types" "github.com/labstack/echo/v4" ) @@ -61,12 +60,11 @@ func handleGetServerConfig(c echo.Context) error { func handleGetDashboardCharts(c echo.Context) error { var ( app = c.Get("app").(*App) - out types.JSONText ) - if err := app.queries.GetDashboardCharts.Get(&out); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err))) + out, err := app.core.GetDashboardCharts() + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -76,12 +74,11 @@ func handleGetDashboardCharts(c echo.Context) error { func handleGetDashboardCounts(c echo.Context) error { var ( app = c.Get("app").(*App) - out types.JSONText ) - if err := app.queries.GetDashboardCounts.Get(&out); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err))) + out, err := app.core.GetDashboardCounts() + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) diff --git a/cmd/bounce.go b/cmd/bounce.go index e7005df..a38001b 100644 --- a/cmd/bounce.go +++ b/cmd/bounce.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "io/ioutil" "net/http" "strconv" @@ -10,23 +9,13 @@ import ( "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" - "github.com/lib/pq" ) -type bouncesWrap struct { - Results []models.Bounce `json:"results"` - - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - // handleGetBounces handles retrieval of bounce records. func handleGetBounces(c echo.Context) error { var ( app = c.Get("app").(*App) pg = getPagination(c.QueryParams(), 50) - out bouncesWrap id, _ = strconv.Atoi(c.Param("id")) campID, _ = strconv.Atoi(c.QueryParam("campaign_id")) @@ -35,38 +24,30 @@ func handleGetBounces(c echo.Context) error { order = c.FormValue("order") ) - // Fetch one list. - single := false + // Fetch one bounce. if id > 0 { - single = true + out, err := app.core.GetBounce(id) + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } - // Sort params. - if !strSliceContains(orderBy, bounceQuerySortFields) { - orderBy = "created_at" - } - if order != sortAsc && order != sortDesc { - order = sortDesc + res, err := app.core.QueryBounces(campID, 0, source, orderBy, order, pg.Offset, pg.Limit) + if err != nil { + return err } - stmt := fmt.Sprintf(app.queries.QueryBounces, orderBy, order) - if err := db.Select(&out.Results, stmt, id, campID, 0, source, pg.Offset, pg.Limit); err != nil { - app.log.Printf("error fetching bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) - } - if len(out.Results) == 0 { + // No results. + var out models.PageResults + if len(res) == 0 { out.Results = []models.Bounce{} return c.JSON(http.StatusOK, okResp{out}) } - if single { - return c.JSON(http.StatusOK, okResp{out.Results[0]}) - } - // Meta. - out.Total = out.Results[0].Total + out.Results = res + out.Total = res[0].Total out.Page = pg.Page out.PerPage = pg.PerPage @@ -76,22 +57,17 @@ func handleGetBounces(c echo.Context) error { // handleGetSubscriberBounces retrieves a subscriber's bounce records. func handleGetSubscriberBounces(c echo.Context) error { var ( - app = c.Get("app").(*App) - subID = c.Param("id") + app = c.Get("app").(*App) + subID, _ = strconv.Atoi(c.Param("id")) ) - id, _ := strconv.ParseInt(subID, 10, 64) - if id < 1 { + if subID < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - out := []models.Bounce{} - stmt := fmt.Sprintf(app.queries.QueryBounces, "created_at", "ASC") - if err := db.Select(&out, stmt, 0, 0, subID, "", 0, 1000); err != nil { - app.log.Printf("error fetching bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + out, err := app.core.QueryBounces(0, subID, "", "", "", 0, 1000) + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -103,12 +79,12 @@ func handleDeleteBounces(c echo.Context) error { app = c.Get("app").(*App) pID = c.Param("id") all, _ = strconv.ParseBool(c.QueryParam("all")) - IDs = pq.Int64Array{} + IDs = []int{} ) // Is it an /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } @@ -128,11 +104,8 @@ func handleDeleteBounces(c echo.Context) error { IDs = i } - if _, err := app.queries.DeleteBounces.Exec(IDs); err != nil { - app.log.Printf("error deleting bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + if err := app.core.DeleteBounces(IDs); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 9ee1e03..a35b837 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "database/sql" "errors" "fmt" "html/template" @@ -13,12 +12,9 @@ import ( "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 @@ -27,18 +23,16 @@ type campaignReq struct { models.Campaign // Indicates if the "send_at" date should be written or set to null. - SendLater bool `db:"-" json:"send_later"` + SendLater bool `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"` + ListIDs []int `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 @@ -49,43 +43,8 @@ type campaignContentReq struct { 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"} + regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`) ) // handleGetCampaigns handles retrieval of campaigns. @@ -93,9 +52,7 @@ 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") @@ -103,61 +60,52 @@ func handleGetCampaigns(c echo.Context) error { noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) ) - // Fetch one campaign. - single := false - if id > 0 { - single = true + res, err := app.core.QueryCampaigns(query, status, orderBy, order, pg.Offset, pg.Limit) + if err != nil { + return err } - queryStr, stmt := makeSearchQuery(query, orderBy, order, app.queries.QueryCampaigns) + if noBody { + for i := 0; i < len(res); i++ { + res[i].Body = "" + } + } - // 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 { + var out models.PageResults + if len(res) == 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.Results = res + out.Total = res[0].Total out.Page = pg.Page out.PerPage = pg.PerPage return c.JSON(http.StatusOK, okResp{out}) } +// handleGetCampaign handles retrieval of campaigns. +func handleGetCampaign(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) + ) + + out, err := app.core.GetCampaign(id, "") + if err != nil { + return err + } + + if noBody { + out.Body = "" + } + + return c.JSON(http.StatusOK, okResp{out}) +} + // handlePreviewCampaign renders the HTML preview of a campaign body. func handlePreviewCampaign(c echo.Context) error { var ( @@ -170,17 +118,9 @@ func handlePreviewCampaign(c echo.Context) error { 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))) + camp, err := app.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. @@ -274,45 +214,12 @@ func handleCreateCampaign(c echo.Context) error { o = c } - uu, err := uuid.NewV4() + out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs) 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())) + return err } - // 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), - })) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateCampaign handles campaign modification. @@ -328,17 +235,9 @@ func handleUpdateCampaign(c echo.Context) error { } - 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))) + cm, err := app.core.GetCampaign(id, "") + if err != nil { + return err } if isCampaignalMutable(cm.Status) { @@ -359,28 +258,12 @@ func handleUpdateCampaign(c echo.Context) error { 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) + out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.SendLater) 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 err } - return handleGetCampaigns(c) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateCampaignStatus handles campaign status modification. @@ -394,73 +277,20 @@ func handleUpdateCampaignStatus(c echo.Context) error { 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))) + var o struct { + Status string `json:"status"` } - // 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) + out, err := app.core.UpdateCampaignStatus(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))) + return 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) + return c.JSON(http.StatusOK, okResp{out}) } // handleDeleteCampaign handles campaign deletion. @@ -475,26 +305,8 @@ func handleDeleteCampaign(c echo.Context) error { 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))) - + if err := app.core.DeleteCampaign(id); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -504,19 +316,14 @@ func handleDeleteCampaign(c echo.Context) error { 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{}{}}) - } + out, err := app.core.GetRunningCampaignStats() + if err != nil { + return err + } - 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 { + if len(out) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } @@ -577,29 +384,16 @@ func handleTestCampaign(c echo.Context) error { 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")) + + subs, err := app.core.GetSubscribersByEmail(req.SubscriberEmails) + if err != nil { + return err } // 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))) + camp, err := app.core.GetCampaignForPreview(campID, tplID) + if err != nil { + return err } // Override certain values from the DB with incoming values. @@ -647,38 +441,24 @@ func handleGetCampaignViewAnalytics(c echo.Context) error { 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))) + // Campaign link stats. + if typ == "links" { + out, err := app.core.GetCampaignAnalyticsLinks(ids, typ, from, to) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) + } + + // View, click, bounce stats. + out, err := app.core.GetCampaignAnalyticsCounts(ids, typ, from, to) + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -766,13 +546,9 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { } // 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) + lists, err := app.core.GetListsByOptin(o.ListIDs, models.ListOptinDouble) 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))) + return o, err } // No opt-in lists. @@ -802,22 +578,3 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, 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) -} diff --git a/cmd/handlers.go b/cmd/handlers.go index 1544818..3758401 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -86,6 +86,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) { g.DELETE("/api/subscribers", handleDeleteSubscribers) g.GET("/api/bounces", handleGetBounces) + g.GET("/api/bounces/:id", handleGetBounces) g.DELETE("/api/bounces", handleDeleteBounces) g.DELETE("/api/bounces/:id", handleDeleteBounces) @@ -111,7 +112,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) { g.GET("/api/campaigns", handleGetCampaigns) g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats) - g.GET("/api/campaigns/:id", handleGetCampaigns) + g.GET("/api/campaigns/:id", handleGetCampaign) g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics) g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) @@ -124,6 +125,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) { g.DELETE("/api/campaigns/:id", handleDeleteCampaign) g.GET("/api/media", handleGetMedia) + g.GET("/api/media/:id", handleGetMedia) g.POST("/api/media", handleUploadMedia) g.DELETE("/api/media/:id", handleDeleteMedia) @@ -264,19 +266,17 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc subUUID = c.Param("subUUID") ) - var exists bool - if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil { + if _, err := app.core.GetSubscriber(0, subUUID, ""); err != nil { + if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", er.Message.(string))) + } + app.log.Printf("error checking subscriber existence: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) } - if !exists { - return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", - app.i18n.T("public.subNotFound"))) - } return next(c) } } @@ -319,23 +319,3 @@ func getPagination(q url.Values, perPage int) pagination { Limit: perPage, } } - -// copyEchoCtx returns a copy of the the current echo.Context in a request -// with the given params set for the active handler to proxy the request -// to another handler without mutating its context. -func copyEchoCtx(c echo.Context, params map[string]string) echo.Context { - var ( - keys = make([]string, 0, len(params)) - vals = make([]string, 0, len(params)) - ) - for k, v := range params { - keys = append(keys, k) - vals = append(vals, v) - } - - b := c.Echo().NewContext(c.Request(), c.Response()) - b.Set("app", c.Get("app").(*App)) - b.SetParamNames(keys...) - b.SetParamValues(vals...) - return b -} diff --git a/cmd/init.go b/cmd/init.go index 03af623..ec32acf 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -36,6 +36,7 @@ import ( "github.com/knadh/listmonk/models" "github.com/knadh/stuffbin" "github.com/labstack/echo/v4" + "github.com/lib/pq" flag "github.com/spf13/pflag" ) @@ -236,16 +237,32 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem // initDB initializes the main DB connection pool and parse and loads the app's // SQL queries into a prepared query map. func initDB() *sqlx.DB { - var dbCfg dbConf - if err := ko.Unmarshal("db", &dbCfg); err != nil { + var c struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + User string `koanf:"user"` + Password string `koanf:"password"` + DBName string `koanf:"database"` + SSLMode string `koanf:"ssl_mode"` + MaxOpen int `koanf:"max_open"` + MaxIdle int `koanf:"max_idle"` + MaxLifetime time.Duration `koanf:"max_lifetime"` + } + if err := ko.Unmarshal("db", &c); err != nil { lo.Fatalf("error loading db config: %v", err) } - lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName) - db, err := connectDB(dbCfg) + lo.Printf("connecting to db: %s:%d/%s", c.Host, c.Port, c.DBName) + db, err := sqlx.Connect("postgres", + fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode)) if err != nil { lo.Fatalf("error connecting to DB: %v", err) } + + db.SetMaxOpenConns(c.MaxOpen) + db.SetMaxIdleConns(c.MaxIdle) + db.SetConnMaxLifetime(c.MaxLifetime) + return db } @@ -265,7 +282,7 @@ func readQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem) goyesql.Qu } // prepareQueries queries prepares a query map and returns a *Queries -func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *Queries { +func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.Queries { // The campaign view/click count queries have a COUNT(%s) placeholder that should either // be substituted with * to pull non-unique rows when individual subscriber tracking is off // as all subscriber_ids will be null, or with DISTINCT subscriber_id when tracking is on @@ -281,7 +298,7 @@ func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *Queries } // Scan and prepare all queries. - var q Queries + var q models.Queries if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil { lo.Fatalf("error preparing SQL queries: %v", err) } @@ -293,7 +310,14 @@ func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *Queries func initSettings(query string, db *sqlx.DB, ko *koanf.Koanf) { var s types.JSONText if err := db.Get(&s, query); err != nil { - lo.Fatalf("error reading settings from DB: %s", pqErrMsg(err)) + msg := err.Error() + if err, ok := err.(*pq.Error); ok { + if err.Detail != "" { + msg = fmt.Sprintf("%s. %s", err, err.Detail) + } + } + + lo.Fatalf("error reading settings from DB: %s", msg) } // Setting keys are dot separated, eg: app.favicon_url. Unflatten them into @@ -365,7 +389,7 @@ func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n { } // initCampaignManager initializes the campaign manager. -func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { +func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Manager { campNotifCB := func(subject string, data interface{}) error { return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data) } @@ -403,7 +427,7 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { } // initImporter initializes the bulk subscriber importer. -func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer { +func initImporter(q *models.Queries, db *sqlx.DB, app *App) *subimporter.Importer { return subimporter.New( subimporter.Options{ DomainBlocklist: app.constants.Privacy.DomainBlocklist, diff --git a/cmd/lists.go b/cmd/lists.go index a580f5a..66a3e1f 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -1,35 +1,19 @@ package main import ( - "fmt" "net/http" "strconv" "strings" - "github.com/gofrs/uuid" "github.com/knadh/listmonk/models" - "github.com/lib/pq" - "github.com/labstack/echo/v4" ) -type listsWrap struct { - Results []models.List `json:"results"` - - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - -var ( - listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"} -) - // handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow. func handleGetLists(c echo.Context) error { var ( app = c.Get("app").(*App) - out listsWrap + out models.PageResults pg = getPagination(c.QueryParams(), 20) query = strings.TrimSpace(c.FormValue("query")) @@ -47,65 +31,56 @@ func handleGetLists(c echo.Context) error { // Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast. if !single && minimal { - if err := app.queries.GetLists.Select(&out.Results, "", "id"); err != nil { - app.log.Printf("error fetching lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + res, err := app.core.GetLists("") + if err != nil { + return err } - if len(out.Results) == 0 { + if len(res) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } // Meta. - out.Total = out.Results[0].Total + out.Results = res + out.Total = len(res) out.Page = 1 out.PerPage = out.Total - if out.PerPage == 0 { - out.PerPage = out.Total - } + return c.JSON(http.StatusOK, okResp{out}) } - queryStr, stmt := makeSearchQuery(query, orderBy, order, app.queries.QueryLists) - - if err := db.Select(&out.Results, - stmt, - listID, - queryStr, - pg.Offset, - pg.Limit); err != nil { - app.log.Printf("error fetching lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + // Full list query. + res, err := app.core.QueryLists(query, orderBy, order, pg.Offset, pg.Limit) + if err != nil { + return err } - if single && len(out.Results) == 0 { + + if single && len(res) == 0 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) } - if len(out.Results) == 0 { - return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } // Replace null tags. - for i, v := range out.Results { + for i, v := range res { if v.Tags == nil { - out.Results[i].Tags = make(pq.StringArray, 0) + res[i].Tags = make([]string, 0) } // Total counts. for _, c := range v.SubscriberCounts { - out.Results[i].SubscriberCount += c + res[i].SubscriberCount += c } } if single { - return c.JSON(http.StatusOK, okResp{out.Results[0]}) + return c.JSON(http.StatusOK, okResp{res[0]}) } // Meta. - out.Total = out.Results[0].Total + // TODO: add .query? + out.Results = res + if len(res) > 0 { + out.Total = res[0].Total + } out.Page = pg.Page out.PerPage = pg.PerPage if out.PerPage == 0 { @@ -119,51 +94,24 @@ func handleGetLists(c echo.Context) error { func handleCreateList(c echo.Context) error { var ( app = c.Get("app").(*App) - o = models.List{} + l = models.List{} ) - if err := c.Bind(&o); err != nil { + if err := c.Bind(&l); err != nil { return err } // Validate. - if !strHasLen(o.Name, 1, stdInputMaxLen) { + if !strHasLen(l.Name, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) } - uu, err := uuid.NewV4() + out, err := app.core.CreateList(l) 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())) + return err } - if o.Type == "" { - o.Type = models.ListTypePrivate - } - if o.Optin == "" { - o.Optin = models.ListOptinSingle - } - - // Insert and read ID. - var newID int - o.UUID = uu.String() - if err := app.queries.CreateList.Get(&newID, - o.UUID, - o.Name, - o.Type, - o.Optin, - pq.StringArray(normalizeTags(o.Tags))); err != nil { - app.log.Printf("error creating list: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) - } - - // Hand over to the GET handler to return the last insertion. - return handleGetLists(copyEchoCtx(c, map[string]string{ - "id": fmt.Sprintf("%d", newID), - })) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateList handles list modification. @@ -178,26 +126,22 @@ func handleUpdateList(c echo.Context) error { } // Incoming params. - var o models.List - if err := c.Bind(&o); err != nil { + var l models.List + if err := c.Bind(&l); err != nil { return err } - res, err := app.queries.UpdateList.Exec(id, - o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags))) + // Validate. + if !strHasLen(l.Name, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) + } + + out, err := app.core.UpdateList(id, l) if err != nil { - app.log.Printf("error updating list: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + return err } - if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) - } - - return handleGetLists(c) + return c.JSON(http.StatusOK, okResp{out}) } // handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list. @@ -205,7 +149,7 @@ func handleDeleteLists(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.ParseInt(c.Param("id"), 10, 64) - ids pq.Int64Array + ids []int ) if id < 1 && len(ids) == 0 { @@ -213,14 +157,11 @@ func handleDeleteLists(c echo.Context) error { } if id > 0 { - ids = append(ids, id) + ids = append(ids, int(id)) } - if _, err := app.queries.DeleteLists.Exec(ids); err != nil { - app.log.Printf("error deleting lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + if err := app.core.DeleteLists(ids); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) diff --git a/cmd/main.go b/cmd/main.go index b759037..20f55de 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,11 +17,13 @@ import ( "github.com/knadh/koanf/providers/env" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/buflog" + "github.com/knadh/listmonk/internal/core" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/subimporter" + "github.com/knadh/listmonk/models" "github.com/knadh/stuffbin" ) @@ -32,9 +34,10 @@ const ( // App contains the "global" components that are // passed around, especially through HTTP handlers. type App struct { + core *core.Core fs stuffbin.FileSystem db *sqlx.DB - queries *Queries + queries *models.Queries constants *constants manager *manager.Manager importer *subimporter.Importer @@ -67,7 +70,7 @@ var ( ko = koanf.New(".") fs stuffbin.FileSystem db *sqlx.DB - queries *Queries + queries *models.Queries // Compile-time variables. buildString string @@ -168,6 +171,18 @@ func main() { // Load i18n language map. app.i18n = initI18n(app.constants.Lang, fs) + app.core = core.New(&core.Opt{ + Constants: core.Constants{ + SendOptinConfirmation: app.constants.SendOptinConfirmation, + }, + Queries: queries, + DB: db, + I18n: app.i18n, + Log: lo, + }, &core.Hooks{ + SendOptinConfirmation: sendOptinConfirmationHook(app), + }) + app.queries = queries app.manager = initCampaignManager(app.queries, app.constants, app) app.importer = initImporter(app.queries, db, app) diff --git a/cmd/manager_store.go b/cmd/manager_store.go index 5ff5e55..4275429 100644 --- a/cmd/manager_store.go +++ b/cmd/manager_store.go @@ -9,10 +9,10 @@ import ( // runnerDB implements runner.DataSource over the primary // database. type runnerDB struct { - queries *Queries + queries *models.Queries } -func newManagerStore(q *Queries) *runnerDB { +func newManagerStore(q *models.Queries) *runnerDB { return &runnerDB{ queries: q, } diff --git a/cmd/media.go b/cmd/media.go index 16b81a5..e8aa44d 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -8,8 +8,6 @@ import ( "strconv" "github.com/disintegration/imaging" - "github.com/gofrs/uuid" - "github.com/knadh/listmonk/internal/media" "github.com/labstack/echo/v4" ) @@ -97,20 +95,11 @@ func handleUploadMedia(c echo.Context) error { app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) } - 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())) - } - // Write to the DB. - if _, err := app.queries.InsertMedia.Exec(uu, fName, thumbfName, app.constants.MediaProvider); err != nil { + // TODO: cleanup + if _, err := app.core.InsertMedia(fName, thumbfName, app.constants.MediaProvider, app.media); err != nil { cleanUp = true - app.log.Printf("error inserting uploaded file to db: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + return err } return c.JSON(http.StatusOK, okResp{true}) } @@ -118,19 +107,22 @@ func handleUploadMedia(c echo.Context) error { // handleGetMedia handles retrieval of uploaded media. func handleGetMedia(c echo.Context) error { var ( - app = c.Get("app").(*App) - out = []media.Media{} + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) ) - if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + // Fetch one list. + if id > 0 { + out, err := app.core.GetMedia(id, "", app.media) + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } - for i := 0; i < len(out); i++ { - out[i].URL = app.media.Get(out[i].Filename) - out[i].ThumbURL = app.media.Get(out[i].Thumb) + out, err := app.core.GetAllMedia(app.constants.MediaProvider, app.media) + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -147,15 +139,14 @@ func handleDeleteMedia(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var m media.Media - if err := app.queries.DeleteMedia.Get(&m, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + fname, err := app.core.DeleteMedia(id) + if err != nil { + return err } - app.media.Delete(m.Filename) - app.media.Delete(thumbPrefix + m.Filename) + app.media.Delete(fname) + app.media.Delete(thumbPrefix + fname) + return c.JSON(http.StatusOK, okResp{true}) } diff --git a/cmd/public.go b/cmd/public.go index 155e7a0..b219f7d 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -73,11 +73,6 @@ type subFormTpl struct { Lists []models.List } -type subForm struct { - subimporter.SubReq - SubListUUIDs []string `form:"l"` -} - var ( pixelPNG = drawTransparentImage(3, 14) ) @@ -103,40 +98,37 @@ func handleViewCampaignMessage(c echo.Context) error { ) // Get the campaign. - var camp models.Campaign - if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil { - if err == sql.ErrNoRows { - return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", - app.i18n.T("public.campaignNotFound"))) + camp, err := app.core.GetCampaign(0, campUUID) + if err != nil { + if er, ok := err.(*echo.HTTPError); ok { + if er.Code == http.StatusBadRequest { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound"))) + } } app.log.Printf("error fetching campaign: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } // Get the subscriber. - sub, err := getSubscriber(0, subUUID, "", app) + sub, err := app.core.GetSubscriber(0, subUUID, "") if err != nil { if err == sql.ErrNoRows { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", - app.i18n.T("public.errorFetchingEmail"))) + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.errorFetchingEmail"))) } return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } // Compile the template. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { app.log.Printf("error compiling template: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } // Render the message body. @@ -144,8 +136,7 @@ func handleViewCampaignMessage(c echo.Context) error { if err != nil { app.log.Printf("error rendering message: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } return c.HTML(http.StatusOK, string(msg.Body())) @@ -176,16 +167,13 @@ func handleSubscriptionPage(c echo.Context) error { blocklist = false } - if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil { - app.log.Printf("error unsubscribing: %v", err) + if err := app.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", - app.i18n.T("public.unsubbedInfo"))) + makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo"))) } return c.Render(http.StatusOK, "subscription", out) @@ -215,40 +203,34 @@ func handleOptinPage(c echo.Context) error { for _, l := range out.ListUUIDs { if !reUUID.MatchString(l) { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("globals.messages.invalidUUID"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidUUID"))) } } } // Get the list of subscription lists where the subscriber hasn't confirmed. - if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, - nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil { - app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) - + lists, err := app.core.GetSubscriberLists(0, subUUID, nil, out.ListUUIDs, models.SubscriptionStatusUnconfirmed, "") + if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingLists"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists"))) } // There are no lists to confirm. - if len(out.Lists) == 0 { + if len(lists) == 0 { return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo"))) } // Confirm. if confirm { - if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil { + if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs); err != nil { app.log.Printf("error unsubscribing: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", - app.i18n.Ts("public.subConfirmed"))) + makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", app.i18n.Ts("public.subConfirmed"))) } return c.Render(http.StatusOK, "optin", out) @@ -263,23 +245,19 @@ func handleSubscriptionFormPage(c echo.Context) error { if !app.constants.EnablePublicSubPage { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature"))) } // Get all public lists. - var lists []models.List - if err := app.queries.GetLists.Select(&lists, models.ListTypePublic, "name"); err != nil { - app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err)) + lists, err := app.core.GetLists(models.ListTypePublic) + if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingLists"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists"))) } if len(lists) == 0 { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.noListsAvailable"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.noListsAvailable"))) } out := subFormTpl{} @@ -294,7 +272,10 @@ func handleSubscriptionFormPage(c echo.Context) error { func handleSubscriptionForm(c echo.Context) error { var ( app = c.Get("app").(*App) - req subForm + req struct { + subimporter.SubReq + SubListUUIDs []string `form:"l"` + } ) // Get and validate fields. @@ -305,15 +286,13 @@ func handleSubscriptionForm(c echo.Context) error { // If there's a nonce value, a bot could've filled the form. if c.FormValue("nonce") != "" { return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature"))) } if len(req.SubListUUIDs) == 0 { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("public.noListsSelected"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.noListsSelected"))) } // If there's no name, use the name bit from the e-mail. @@ -323,17 +302,28 @@ func handleSubscriptionForm(c echo.Context) error { } // Validate fields. - if r, err := app.importer.ValidateFields(req.SubReq); err != nil { - return c.Render(http.StatusInternalServerError, tplMessage, + if len(req.Email) > 1000 { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidEmail"))) + } + + em, err := app.importer.SanitizeEmail(req.Email) + if err != nil { + return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error())) - } else { - req.SubReq = r + } + req.Email = em + + req.Name = strings.TrimSpace(req.Name) + if len(req.Name) == 0 || len(req.Name) > stdInputMaxLen { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName"))) } // Insert the subscriber into the DB. req.Status = models.SubscriberStatusEnabled req.ListUUIDs = pq.StringArray(req.SubListUUIDs) - _, _, hasOptin, err := insertSubscriber(req.SubReq, app) + _, _, hasOptin, err := app.core.CreateSubscriber(req.SubReq.Subscriber, nil, req.ListUUIDs, false) if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message))) @@ -364,17 +354,9 @@ func handleLinkRedirect(c echo.Context) error { } var url string - if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { - if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" { - return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidLink"))) - } - - app.log.Printf("error fetching redirect link: %s", err) - return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + if err := app.core.RegisterCampaignLinkClick(linkUUID, campUUID, subUUID); err != nil { + e := err.(*echo.HTTPError) + return c.Render(e.Code, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", e.Error())) } return c.Redirect(http.StatusTemporaryRedirect, url) @@ -398,7 +380,7 @@ func handleRegisterCampaignView(c echo.Context) error { // Exclude dummy hits from template previews. if campUUID != dummyUUID && subUUID != dummyUUID { - if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil { + if err := app.core.RegisterCampaignView(campUUID, subUUID); err != nil { app.log.Printf("error registering campaign view: %s", err) } } @@ -419,8 +401,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { // Is export allowed? if !app.constants.Privacy.AllowExport { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature"))) } // Get the subscriber's data. A single query that gets the profile, @@ -430,8 +411,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { if err != nil { app.log.Printf("error exporting subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } // Prepare the attachment e-mail. @@ -439,8 +419,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { if err := app.notifTpls.tpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil { app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } // Send the data as a JSON attachment to the subscriber. @@ -461,13 +440,11 @@ func handleSelfExportSubscriberData(c echo.Context) error { }); err != nil { app.log.Printf("error e-mailing subscriber profile: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", - app.i18n.T("public.dataSent"))) + makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", app.i18n.T("public.dataSent"))) } // handleWipeSubscriberData allows a subscriber to delete their data. The @@ -482,20 +459,17 @@ func handleWipeSubscriberData(c echo.Context) error { // Is wiping allowed? if !app.constants.Privacy.AllowWipe { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature"))) } - if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil { + if err := app.core.DeleteSubscribers(nil, []string{subUUID}); err != nil { app.log.Printf("error wiping subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", - app.i18n.T("public.dataRemoved"))) + makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", app.i18n.T("public.dataRemoved"))) } // drawTransparentImage draws a transparent PNG of given dimensions diff --git a/cmd/settings.go b/cmd/settings.go index 0c02595..55c98cb 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "net/http" "regexp" "strings" @@ -9,110 +8,10 @@ import ( "time" "github.com/gofrs/uuid" - "github.com/jmoiron/sqlx/types" + "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) -type settings struct { - AppRootURL string `json:"app.root_url"` - AppLogoURL string `json:"app.logo_url"` - AppFaviconURL string `json:"app.favicon_url"` - AppFromEmail string `json:"app.from_email"` - AppNotifyEmails []string `json:"app.notify_emails"` - EnablePublicSubPage bool `json:"app.enable_public_subscription_page"` - SendOptinConfirmation bool `json:"app.send_optin_confirmation"` - CheckUpdates bool `json:"app.check_updates"` - AppLang string `json:"app.lang"` - - AppBatchSize int `json:"app.batch_size"` - AppConcurrency int `json:"app.concurrency"` - AppMaxSendErrors int `json:"app.max_send_errors"` - AppMessageRate int `json:"app.message_rate"` - - AppMessageSlidingWindow bool `json:"app.message_sliding_window"` - AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"` - AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"` - - PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` - PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` - PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` - PrivacyAllowExport bool `json:"privacy.allow_export"` - PrivacyAllowWipe bool `json:"privacy.allow_wipe"` - PrivacyExportable []string `json:"privacy.exportable"` - DomainBlocklist []string `json:"privacy.domain_blocklist"` - - UploadProvider string `json:"upload.provider"` - UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"` - UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"` - UploadS3URL string `json:"upload.s3.url"` - UploadS3PublicURL string `json:"upload.s3.public_url"` - UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"` - UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"` - UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"` - UploadS3Bucket string `json:"upload.s3.bucket"` - UploadS3BucketDomain string `json:"upload.s3.bucket_domain"` - UploadS3BucketPath string `json:"upload.s3.bucket_path"` - UploadS3BucketType string `json:"upload.s3.bucket_type"` - UploadS3Expiry string `json:"upload.s3.expiry"` - - SMTP []struct { - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Host string `json:"host"` - HelloHostname string `json:"hello_hostname"` - Port int `json:"port"` - AuthProtocol string `json:"auth_protocol"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - EmailHeaders []map[string]string `json:"email_headers"` - MaxConns int `json:"max_conns"` - MaxMsgRetries int `json:"max_msg_retries"` - IdleTimeout string `json:"idle_timeout"` - WaitTimeout string `json:"wait_timeout"` - TLSType string `json:"tls_type"` - TLSSkipVerify bool `json:"tls_skip_verify"` - } `json:"smtp"` - - Messengers []struct { - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Name string `json:"name"` - RootURL string `json:"root_url"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - MaxConns int `json:"max_conns"` - Timeout string `json:"timeout"` - MaxMsgRetries int `json:"max_msg_retries"` - } `json:"messengers"` - - BounceEnabled bool `json:"bounce.enabled"` - BounceEnableWebhooks bool `json:"bounce.webhooks_enabled"` - BounceCount int `json:"bounce.count"` - BounceAction string `json:"bounce.action"` - SESEnabled bool `json:"bounce.ses_enabled"` - SendgridEnabled bool `json:"bounce.sendgrid_enabled"` - SendgridKey string `json:"bounce.sendgrid_key"` - BounceBoxes []struct { - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - AuthProtocol string `json:"auth_protocol"` - ReturnPath string `json:"return_path"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - TLSEnabled bool `json:"tls_enabled"` - TLSSkipVerify bool `json:"tls_skip_verify"` - ScanInterval string `json:"scan_interval"` - } `json:"bounce.mailboxes"` - - AdminCustomCSS string `json:"appearance.admin.custom_css"` - AdminCustomJS string `json:"appearance.admin.custom_js"` - PublicCustomCSS string `json:"appearance.public.custom_css"` - PublicCustomJS string `json:"appearance.public.custom_js"` -} - var ( reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`) ) @@ -121,7 +20,7 @@ var ( func handleGetSettings(c echo.Context) error { app := c.Get("app").(*App) - s, err := getSettings(app) + s, err := app.core.GetSettings() if err != nil { return err } @@ -146,7 +45,7 @@ func handleGetSettings(c echo.Context) error { func handleUpdateSettings(c echo.Context) error { var ( app = c.Get("app").(*App) - set settings + set models.Settings ) // Unmarshal and marshal the fields once to sanitize the settings blob. @@ -155,7 +54,7 @@ func handleUpdateSettings(c echo.Context) error { } // Get the existing settings. - cur, err := getSettings(app) + cur, err := app.core.GetSettings() if err != nil { return err } @@ -263,18 +162,9 @@ func handleUpdateSettings(c echo.Context) error { } set.DomainBlocklist = doms - // Marshal settings. - b, err := json.Marshal(set) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("settings.errorEncoding", "error", err.Error())) - } - // Update the settings in the DB. - if _, err := app.queries.UpdateSettings.Exec(b); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.settings}", "error", pqErrMsg(err))) + if err := app.core.UpdateSettings(set); err != nil { + return err } // If there are any active campaigns, don't do an auto reload and @@ -303,24 +193,3 @@ func handleGetLogs(c echo.Context) error { app := c.Get("app").(*App) return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()}) } - -func getSettings(app *App) (settings, error) { - var ( - b types.JSONText - out settings - ) - - if err := app.queries.GetSettings.Get(&b); err != nil { - return out, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.settings}", "error", pqErrMsg(err))) - } - - // Unmarshal the settings and filter out sensitive fields. - if err := json.Unmarshal([]byte(b), &out); err != nil { - return out, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("settings.errorEncoding", "error", err.Error())) - } - - return out, nil -} diff --git a/cmd/subscribers.go b/cmd/subscribers.go index 49bea28..0b775a6 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -1,8 +1,6 @@ package main import ( - "context" - "database/sql" "encoding/csv" "encoding/json" "errors" @@ -12,11 +10,8 @@ import ( "strconv" "strings" - "github.com/gofrs/uuid" - "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" - "github.com/lib/pq" ) const ( @@ -26,29 +21,12 @@ const ( // subQueryReq is a "catch all" struct for reading various // subscriber related requests. type subQueryReq struct { - Query string `json:"query"` - ListIDs pq.Int64Array `json:"list_ids"` - TargetListIDs pq.Int64Array `json:"target_list_ids"` - SubscriberIDs pq.Int64Array `json:"ids"` - Action string `json:"action"` - Status string `json:"status"` -} - -type subsWrap struct { - Results models.Subscribers `json:"results"` - - Query string `json:"query"` - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - -type subUpdateReq struct { - models.Subscriber - RawAttribs json.RawMessage `json:"attribs"` - Lists pq.Int64Array `json:"lists"` - ListUUIDs pq.StringArray `json:"list_uuids"` - PreconfirmSubs bool `json:"preconfirm_subscriptions"` + Query string `json:"query"` + ListIDs []int `json:"list_ids"` + TargetListIDs []int `json:"target_list_ids"` + SubscriberIDs []int `json:"ids"` + Action string `json:"action"` + Status string `json:"status"` } // subProfileData represents a subscriber's collated data in JSON @@ -63,7 +41,7 @@ type subProfileData struct { // subOptin contains the data that's passed to the double opt-in e-mail template. type subOptin struct { - *models.Subscriber + models.Subscriber OptinURL string UnsubURL string @@ -94,12 +72,12 @@ func handleGetSubscriber(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - sub, err := getSubscriber(id, "", "", app) + out, err := app.core.GetSubscriber(id, "", "") if err != nil { return err } - return c.JSON(http.StatusOK, okResp{sub}) + return c.JSON(http.StatusOK, okResp{out}) } // handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression. @@ -112,7 +90,7 @@ func handleQuerySubscribers(c echo.Context) error { query = sanitizeSQLExp(c.FormValue("query")) orderBy = c.FormValue("order_by") order = c.FormValue("order") - out = subsWrap{Results: make([]models.Subscriber, 0, 1)} + out models.PageResults ) // Limit the subscribers to specific lists? @@ -121,67 +99,13 @@ func handleQuerySubscribers(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - // There's an arbitrary query condition. - cond := "" - if query != "" { - cond = " AND " + query - } - - // Sort params. - if !strSliceContains(orderBy, subQuerySortFields) { - orderBy = "subscribers.id" - } - if order != sortAsc && order != sortDesc { - order = sortDesc - } - - // Create a readonly transaction that just does COUNT() to obtain the count of results - // and to ensure that the arbitrary query is indeed readonly. - stmt := fmt.Sprintf(app.queries.QuerySubscribersCount, cond) - tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) + res, total, err := app.core.QuerySubscribers(query, listIDs, order, orderBy, pg.Offset, pg.Limit) if err != nil { - app.log.Printf("error preparing subscriber query: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) - } - defer tx.Rollback() - - // Execute the readonly query and get the count of results. - var total = 0 - if err := tx.Get(&total, stmt, listIDs); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) - } - - // No results. - if total == 0 { - return c.JSON(http.StatusOK, okResp{out}) - } - - // Run the query again and fetch the actual data. stmt is the raw SQL query. - stmt = fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order) - if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) - } - - // Lazy load lists for each subscriber. - if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { - app.log.Printf("error fetching subscriber lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + return err } out.Query = query - if len(out.Results) == 0 { - out.Results = make(models.Subscribers, 0) - return c.JSON(http.StatusOK, okResp{out}) - } - - // Meta. + out.Results = res out.Total = total out.Page = pg.Page out.PerPage = pg.PerPage @@ -210,41 +134,13 @@ func handleExportSubscribers(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - // There's an arbitrary query condition. - cond := "" - if query != "" { - cond = " AND " + query - } - - stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond) - - // Verify that the arbitrary SQL search expression is read only. - if cond != "" { - tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) - if err != nil { - app.log.Printf("error preparing subscriber query: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) - } - defer tx.Rollback() - - if _, err := tx.Query(stmt, nil, 0, nil, 1); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) - } - } - - // Prepare the actual query statement. - tx, err := db.Preparex(stmt) + // Get the batched export iterator. + exp, err := app.core.ExportSubscribers(query, subIDs, listIDs, app.constants.DBBatchSize) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + return err } - // Run the query until all rows are exhausted. var ( - id = 0 - h = c.Response().Header() wr = csv.NewWriter(c.Response()) ) @@ -257,15 +153,14 @@ func handleExportSubscribers(c echo.Context) error { wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"}) loop: + // Iterate in batches until there are no more subscribers to export. for { - var out []models.SubscriberExport - if err := tx.Select(&out, listIDs, id, subIDs, app.constants.DBBatchSize); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + out, err := exp() + if err != nil { + return err } - if len(out) == 0 { - break loop + if out == nil || len(out) == 0 { + break } for _, r := range out { @@ -275,9 +170,9 @@ loop: break loop } } - wr.Flush() - id = out[len(out)-1].ID + // Flush CSV to stream after each batch. + wr.Flush() } return nil @@ -287,7 +182,12 @@ loop: func handleCreateSubscriber(c echo.Context) error { var ( app = c.Get("app").(*App) - req subimporter.SubReq + req struct { + models.Subscriber + Lists []int `json:"lists"` + ListUUIDs []string `json:"list_uuids"` + PreconfirmSubs bool `json:"preconfirm_subscriptions"` + } ) // Get and validate fields. @@ -295,15 +195,24 @@ func handleCreateSubscriber(c echo.Context) error { return err } - r, err := app.importer.ValidateFields(req) + // Validate fields. + if len(req.Email) > 1000 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail")) + } + + em, err := app.importer.SanitizeEmail(req.Email) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } else { - req = r + } + req.Email = em + + req.Name = strings.TrimSpace(req.Name) + if len(req.Name) == 0 || len(req.Name) > stdInputMaxLen { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } // Insert the subscriber into the DB. - sub, isNew, _, err := insertSubscriber(req, app) + sub, isNew, _, err := app.core.CreateSubscriber(req.Subscriber, req.Lists, req.ListUUIDs, req.PreconfirmSubs) if err != nil { return err } @@ -318,9 +227,14 @@ func handleCreateSubscriber(c echo.Context) error { func handleUpdateSubscriber(c echo.Context) error { var ( app = c.Get("app").(*App) - id, _ = strconv.ParseInt(c.Param("id"), 10, 64) - req subUpdateReq + id, _ = strconv.Atoi(c.Param("id")) + req struct { + models.Subscriber + Lists []int `json:"lists"` + PreconfirmSubs bool `json:"preconfirm_subscriptions"` + } ) + // Get and validate fields. if err := c.Bind(&req); err != nil { return err @@ -340,42 +254,12 @@ func handleUpdateSubscriber(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } - // If there's an attribs value, validate it. - if len(req.RawAttribs) > 0 { - var a models.SubscriberAttribs - if err := json.Unmarshal(req.RawAttribs, &a); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscriber}", "error", err.Error())) - } - } - - subStatus := models.SubscriptionStatusUnconfirmed - if req.PreconfirmSubs { - subStatus = models.SubscriptionStatusConfirmed - } - - _, err := app.queries.UpdateSubscriber.Exec(id, - strings.ToLower(strings.TrimSpace(req.Email)), - strings.TrimSpace(req.Name), - req.Status, - req.RawAttribs, - req.Lists, - subStatus) - if err != nil { - app.log.Printf("error updating subscriber: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) - } - - // Send a confirmation e-mail (if there are any double opt-in lists). - sub, err := getSubscriber(int(id), "", "", app) + out, err := app.core.UpdateSubscriber(id, req.Subscriber, req.Lists, req.PreconfirmSubs) if err != nil { return err } - return c.JSON(http.StatusOK, okResp{sub}) + return c.JSON(http.StatusOK, okResp{out}) } // handleGetSubscriberSendOptin sends an optin confirmation e-mail to a subscriber. @@ -390,17 +274,13 @@ func handleSubscriberSendOptin(c echo.Context) error { } // Fetch the subscriber. - out, err := getSubscriber(id, "", "", app) + out, err := app.core.GetSubscriber(id, "", "") if err != nil { - app.log.Printf("error fetching subscriber: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + return err } - if _, err := sendOptinConfirmation(out, nil, app); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.T("subscribers.errorSendingOptin")) + if _, err := sendOptinConfirmationHook(app)(out, nil); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("subscribers.errorSendingOptin")) } return c.JSON(http.StatusOK, okResp{true}) @@ -410,18 +290,19 @@ func handleSubscriberSendOptin(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleBlocklistSubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - pID = c.Param("id") - IDs pq.Int64Array + app = c.Get("app").(*App) + pID = c.Param("id") + subIDs []int ) // Is it a /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - IDs = append(IDs, id) + + subIDs = append(subIDs, id) } else { // Multiple IDs. var req subQueryReq @@ -433,13 +314,12 @@ func handleBlocklistSubscribers(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No IDs given.") } - IDs = req.SubscriberIDs + + subIDs = req.SubscriberIDs } - if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil { - app.log.Printf("error blocklisting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error())) + if err := app.core.BlocklistSubscribers(subIDs); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -450,18 +330,18 @@ func handleBlocklistSubscribers(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleManageSubscriberLists(c echo.Context) error { var ( - app = c.Get("app").(*App) - pID = c.Param("id") - IDs pq.Int64Array + app = c.Get("app").(*App) + pID = c.Param("id") + subIDs []int ) // Is it an /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - IDs = append(IDs, id) + subIDs = append(subIDs, id) } var req subQueryReq @@ -472,8 +352,8 @@ func handleManageSubscriberLists(c echo.Context) error { if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs")) } - if len(IDs) == 0 { - IDs = req.SubscriberIDs + if len(subIDs) == 0 { + subIDs = req.SubscriberIDs } if len(req.TargetListIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven")) @@ -483,20 +363,17 @@ func handleManageSubscriberLists(c echo.Context) error { var err error switch req.Action { case "add": - _, err = app.queries.AddSubscribersToLists.Exec(IDs, req.TargetListIDs, req.Status) + err = app.core.AddSubscriptions(subIDs, req.TargetListIDs, req.Status) case "remove": - _, err = app.queries.DeleteSubscriptions.Exec(IDs, req.TargetListIDs) + err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs) case "unsubscribe": - _, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs) + err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } if err != nil { - app.log.Printf("error updating subscriptions: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscribers}", "error", err.Error())) + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -506,18 +383,18 @@ func handleManageSubscriberLists(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleDeleteSubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - pID = c.Param("id") - IDs pq.Int64Array + app = c.Get("app").(*App) + pID = c.Param("id") + subIDs []int ) // Is it an /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - IDs = append(IDs, id) + subIDs = append(subIDs, id) } else { // Multiple IDs. i, err := parseStringIDs(c.Request().URL.Query()["id"]) @@ -529,14 +406,11 @@ func handleDeleteSubscribers(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error())) } - IDs = i + subIDs = i } - if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil { - app.log.Printf("error deleting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + if err := app.core.DeleteSubscribers(subIDs, nil); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -554,14 +428,8 @@ func handleDeleteSubscribersByQuery(c echo.Context) error { return err } - err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), - app.queries.DeleteSubscribersByQuery, - req.ListIDs, app.db) - if err != nil { - app.log.Printf("error deleting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + if err := app.core.DeleteSubscribersByQuery(req.Query, req.ListIDs); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -579,13 +447,8 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error { return err } - err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), - app.queries.BlocklistSubscribersByQuery, - req.ListIDs, app.db) - if err != nil { - app.log.Printf("error blocklisting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err))) + if err := app.core.BlocklistSubscribersByQuery(req.Query, req.ListIDs); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -608,25 +471,20 @@ func handleManageSubscriberListsByQuery(c echo.Context) error { } // Action. - var stmt string + var err error switch req.Action { case "add": - stmt = app.queries.AddSubscribersToListsByQuery + err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs) case "remove": - stmt = app.queries.DeleteSubscriptionsByQuery + err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs) case "unsubscribe": - stmt = app.queries.UnsubscribeSubscribersFromListsByQuery + err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } - err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), - stmt, req.ListIDs, app.db, req.TargetListIDs) if err != nil { - app.log.Printf("error updating subscriptions: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -639,16 +497,13 @@ func handleDeleteSubscriberBounces(c echo.Context) error { pID = c.Param("id") ) - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - if _, err := app.queries.DeleteBouncesBySubscriber.Exec(id, nil); err != nil { - app.log.Printf("error deleting bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.bounces}", "error", pqErrMsg(err))) + if err := app.core.DeleteSubscriberBounces(id, ""); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -663,7 +518,8 @@ func handleExportSubscriberData(c echo.Context) error { app = c.Get("app").(*App) pID = c.Param("id") ) - id, _ := strconv.ParseInt(pID, 10, 64) + + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } @@ -684,106 +540,13 @@ func handleExportSubscriberData(c echo.Context) error { return c.Blob(http.StatusOK, "application/json", b) } -// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if -// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation. -func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, bool, bool, error) { - uu, err := uuid.NewV4() - if err != nil { - return req.Subscriber, false, false, err - } - req.UUID = uu.String() - - var ( - isNew = true - subStatus = models.SubscriptionStatusUnconfirmed - ) - if req.PreconfirmSubs { - subStatus = models.SubscriptionStatusConfirmed - } - - if req.Status == "" { - req.Status = models.UserStatusEnabled - } - - if err = app.queries.InsertSubscriber.Get(&req.ID, - req.UUID, - req.Email, - strings.TrimSpace(req.Name), - req.Status, - req.Attribs, - req.Lists, - req.ListUUIDs, - subStatus); err != nil { - if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { - isNew = false - } else { - // return req.Subscriber, errSubscriberExists - app.log.Printf("error inserting subscriber: %v", err) - return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) - } - } - - // Fetch the subscriber's full data. If the subscriber already existed and wasn't - // created, the id will be empty. Fetch the details by e-mail then. - sub, err := getSubscriber(req.ID, "", strings.ToLower(req.Email), app) - if err != nil { - return sub, false, false, err - } - - hasOptin := false - if !req.PreconfirmSubs && app.constants.SendOptinConfirmation { - // Send a confirmation e-mail (if there are any double opt-in lists). - num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app) - hasOptin = num > 0 - } - return sub, isNew, hasOptin, nil -} - -// getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order. -// Only one of these params should have a value. -func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) { - var out models.Subscribers - - if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil { - app.log.Printf("error fetching subscriber: %v", err) - return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) - } - if len(out) == 0 { - return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}")) - } - if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { - app.log.Printf("error loading subscriber lists: %v", err) - return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.lists}", "error", pqErrMsg(err))) - } - - return out[0], nil -} - // exportSubscriberData collates the data of a subscriber including profile, // subscriptions, campaign_views, link_clicks (if they're enabled in the config) // and returns a formatted, indented JSON payload. Either takes a numeric id // and an empty subUUID or takes 0 and a string subUUID. -func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) { - // Get the subscriber's data. A single query that gets the profile, - // list subscriptions, campaign views, and link clicks. Names of - // private lists are replaced with "Private list". - var ( - data subProfileData - uu interface{} - ) - // UUID should be a valid value or a nil. - if subUUID != "" { - uu = subUUID - } - if err := app.queries.ExportSubscriberData.Get(&data, id, uu); err != nil { - app.log.Printf("error fetching subscriber export data: %v", err) +func exportSubscriberData(id int, subUUID string, exportables map[string]bool, app *App) (models.SubscriberExportProfile, []byte, error) { + data, err := app.core.GetSubscriberProfileForExport(id, subUUID) + if err != nil { return data, nil, err } @@ -807,48 +570,10 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app.log.Printf("error marshalling subscriber export data: %v", err) return data, nil, err } + return data, b, nil } -// sendOptinConfirmation sends a double opt-in confirmation e-mail to a subscriber -// if at least one of the given listIDs is set to optin=double. It returns the number of -// opt-in lists that were found. -func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) { - var lists []models.List - - // Fetch double opt-in lists from the given list IDs. - // Get the list of subscription lists where the subscriber hasn't confirmed. - if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil, - pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil { - app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) - return 0, err - } - - // None. - if len(lists) == 0 { - return 0, nil - } - - var ( - out = subOptin{Subscriber: &sub, Lists: lists} - qListIDs = url.Values{} - ) - // Construct the opt-in URL with list IDs. - for _, l := range out.Lists { - qListIDs.Add("l", l.UUID) - } - out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode()) - out.UnsubURL = fmt.Sprintf(app.constants.UnsubURL, dummyUUID, sub.UUID) - - // Send the e-mail. - if err := app.sendNotification([]string{sub.Email}, - app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil { - app.log.Printf("error sending opt-in e-mail: %s", err) - return 0, err - } - return len(lists), nil -} - // sanitizeSQLExp does basic sanitisation on arbitrary // SQL query expressions coming from the frontend. func sanitizeSQLExp(q string) string { @@ -864,8 +589,8 @@ func sanitizeSQLExp(q string) string { return q } -func getQueryInts(param string, qp url.Values) (pq.Int64Array, error) { - out := pq.Int64Array{} +func getQueryInts(param string, qp url.Values) ([]int, error) { + var out []int if vals, ok := qp[param]; ok { for _, v := range vals { if v == "" { @@ -876,9 +601,46 @@ func getQueryInts(param string, qp url.Values) (pq.Int64Array, error) { if err != nil { return nil, err } - out = append(out, int64(listID)) + out = append(out, listID) } } return out, nil } + +// sendOptinConfirmationHook returns an enclosed callback that sends optin confirmation e-mails. +// This is plugged into the 'core' package to send optin confirmations when a new subscriber is +// created via `core.CreateSubscriber()`. +func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []int) (int, error) { + return func(sub models.Subscriber, listIDs []int) (int, error) { + lists, err := app.core.GetSubscriberLists(sub.ID, "", listIDs, nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble) + if err != nil { + return 0, err + } + + // None. + if len(lists) == 0 { + return 0, nil + } + + var ( + out = subOptin{Subscriber: sub, Lists: lists} + qListIDs = url.Values{} + ) + + // Construct the opt-in URL with list IDs. + for _, l := range out.Lists { + qListIDs.Add("l", l.UUID) + } + out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode()) + out.UnsubURL = fmt.Sprintf(app.constants.UnsubURL, dummyUUID, sub.UUID) + + // Send the e-mail. + if err := app.sendNotification([]string{sub.Email}, app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil { + app.log.Printf("error sending opt-in e-mail: %s", err) + return 0, err + } + + return len(lists), nil + } +} diff --git a/cmd/templates.go b/cmd/templates.go index 111168f..e73e23a 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -2,7 +2,6 @@ package main import ( "errors" - "fmt" "net/http" "regexp" "strconv" @@ -34,33 +33,24 @@ var ( func handleGetTemplates(c echo.Context) error { var ( app = c.Get("app").(*App) - out []models.Template id, _ = strconv.Atoi(c.Param("id")) - single = false noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) ) // Fetch one list. if id > 0 { - single = true + out, err := app.core.GetTemplate(id, noBody) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) } - err := app.queries.GetTemplates.Select(&out, id, noBody) + out, err := app.core.GetTemplates(noBody) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.templates}", "error", pqErrMsg(err))) - } - if single && len(out) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) - } - - if len(out) == 0 { - return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } else if single { - return c.JSON(http.StatusOK, okResp{out[0]}) + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -69,11 +59,10 @@ func handleGetTemplates(c echo.Context) error { // handlePreviewTemplate renders the HTML preview of a template. func handlePreviewTemplate(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) body = c.FormValue("body") - - tpls []models.Template ) if body != "" { @@ -86,18 +75,12 @@ func handlePreviewTemplate(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - err := app.queries.GetTemplates.Select(&tpls, id, false) + tpl, err := app.core.GetTemplate(id, false) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.templates}", "error", pqErrMsg(err))) + return err } - if len(tpls) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) - } - body = tpls[0].Body + body = tpl.Body } // Compile the template. @@ -140,20 +123,13 @@ func handleCreateTemplate(c echo.Context) error { return err } - // Insert and read ID. - var newID int - if err := app.queries.CreateTemplate.Get(&newID, - o.Name, - o.Body); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) + out, err := app.core.CreateTemplate(o.Name, []byte(o.Body)) + if err != nil { + return err } - // Hand over to the GET handler to return the last insertion. - return handleGetTemplates(copyEchoCtx(c, map[string]string{ - "id": fmt.Sprintf("%d", newID), - })) + return c.JSON(http.StatusOK, okResp{out}) + } // handleUpdateTemplate handles template modification. @@ -176,19 +152,13 @@ func handleUpdateTemplate(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - res, err := app.queries.UpdateTemplate.Exec(id, o.Name, o.Body) + out, err := app.core.UpdateTemplate(id, o.Name, []byte(o.Body)) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) + return err } - if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) - } + return c.JSON(http.StatusOK, okResp{out}) - return handleGetTemplates(c) } // handleTemplateSetDefault handles template modification. @@ -202,11 +172,8 @@ func handleTemplateSetDefault(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - _, err := app.queries.SetDefaultTemplate.Exec(id) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) + if err := app.core.SetDefaultTemplate(id); err != nil { + return err } return handleGetTemplates(c) @@ -223,16 +190,8 @@ func handleDeleteTemplate(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var delID int - err := app.queries.DeleteTemplate.Get(&delID, id) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) - } - if delID == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.T("templates.cantDeleteDefault")) + if err := app.core.DeleteTemplate(id); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) diff --git a/cmd/utils.go b/cmd/utils.go index 08c3278..1888e6c 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -1,15 +1,12 @@ package main import ( - "bytes" "crypto/rand" "fmt" "path/filepath" "regexp" "strconv" "strings" - - "github.com/lib/pq" ) var ( @@ -37,35 +34,6 @@ func makeFilename(fName string) string { return filepath.Base(name) } -// Given an error, pqErrMsg will try to return pq error details -// if it's a pq error. -func pqErrMsg(err error) string { - if err, ok := err.(*pq.Error); ok { - if err.Detail != "" { - return fmt.Sprintf("%s. %s", err, err.Detail) - } - } - return err.Error() -} - -// normalizeTags takes a list of string tags and normalizes them by -// lower casing and removing all special characters except for dashes. -func normalizeTags(tags []string) []string { - var ( - out []string - dash = []byte("-") - ) - - for _, t := range tags { - rep := regexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash) - - if len(rep) > 0 { - out = append(out, string(rep)) - } - } - return out -} - // makeMsgTpl takes a page title, heading, and message and returns // a msgTpl that can be rendered as an HTML view. This is used for // rendering arbitrary HTML views with error and success messages. @@ -83,10 +51,10 @@ func makeMsgTpl(pageTitle, heading, msg string) msgTpl { // parseStringIDs takes a slice of numeric string IDs and // parses each number into an int64 and returns a slice of the // resultant values. -func parseStringIDs(s []string) ([]int64, error) { - vals := make([]int64, 0, len(s)) +func parseStringIDs(s []string) ([]int, error) { + vals := make([]int, 0, len(s)) for _, v := range s { - i, err := strconv.ParseInt(v, 10, 64) + i, err := strconv.Atoi(v) if err != nil { return nil, err } diff --git a/frontend/cypress/integration/campaigns.js b/frontend/cypress/integration/campaigns.js index 332a54f..f57ca11 100644 --- a/frontend/cypress/integration/campaigns.js +++ b/frontend/cypress/integration/campaigns.js @@ -196,7 +196,7 @@ describe('Campaigns', () => { cy.wait(250); // Verify the changes. - (function (n) { + (function(n) { cy.location('pathname').then((p) => { cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1)}`).should((response) => { const { data } = response.body; diff --git a/internal/core/bounces.go b/internal/core/bounces.go new file mode 100644 index 0000000..f42e13b --- /dev/null +++ b/internal/core/bounces.go @@ -0,0 +1,66 @@ +package core + +import ( + "fmt" + "net/http" + + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "github.com/lib/pq" +) + +var bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"} + +// QueryBounces retrieves bounce entries based on the given params. +func (c *Core) QueryBounces(campID, subID int, source, orderBy, order string, offset, limit int) ([]models.Bounce, error) { + if !strSliceContains(orderBy, bounceQuerySortFields) { + orderBy = "created_at" + } + if order != SortAsc && order != SortDesc { + order = SortDesc + } + + out := []models.Bounce{} + stmt := fmt.Sprintf(c.q.QueryBounces, orderBy, order) + if err := c.db.Select(&out, stmt, 0, campID, subID, source, offset, limit); err != nil { + c.log.Printf("error fetching bounces: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// GetBounce retrieves bounce entries based on the given params. +func (c *Core) GetBounce(id int) (models.Bounce, error) { + var out []models.Bounce + stmt := fmt.Sprintf(c.q.QueryBounces, "id", SortAsc) + if err := c.db.Select(&out, stmt, id, 0, 0, "", 0, 1); err != nil { + c.log.Printf("error fetching bounces: %v", err) + return models.Bounce{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + } + + if len(out) == 0 { + return models.Bounce{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.bounce}")) + + } + + return out[0], nil +} + +// DeleteBounce deletes a list. +func (c *Core) DeleteBounce(id int) error { + return c.DeleteBounces([]int{id}) +} + +// DeleteBounces deletes multiple lists. +func (c *Core) DeleteBounces(ids []int) error { + if _, err := c.q.DeleteBounces.Exec(pq.Array(ids)); err != nil { + c.log.Printf("error deleting lists: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err))) + } + return nil +} diff --git a/internal/core/campaigns.go b/internal/core/campaigns.go new file mode 100644 index 0000000..5590f61 --- /dev/null +++ b/internal/core/campaigns.go @@ -0,0 +1,337 @@ +package core + +import ( + "database/sql" + "net/http" + + "github.com/gofrs/uuid" + "github.com/jmoiron/sqlx" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "github.com/lib/pq" +) + +const ( + CampaignAnalyticsViews = "views" + CampaignAnalyticsClicks = "clicks" + CampaignAnalyticsBounces = "bounces" +) + +// QueryCampaigns retrieves campaigns optionally filtering them by +// the given arbitrary query expression. +func (c *Core) QueryCampaigns(searchStr string, statuses []string, orderBy, order string, offset, limit int) (models.Campaigns, error) { + queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns) + + if statuses == nil { + statuses = []string{} + } + + // Unsafe to ignore scanning fields not present in models.Campaigns. + var out models.Campaigns + if err := c.db.Select(&out, stmt, 0, pq.Array(statuses), queryStr, offset, limit); err != nil { + c.log.Printf("error fetching campaigns: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + for i := 0; i < len(out); i++ { + // Replace null tags. + if out[i].Tags == nil { + out[i].Tags = []string{} + } + } + + // Lazy load stats. + if err := out.LoadStats(c.q.GetCampaignStats); err != nil { + c.log.Printf("error fetching campaign stats: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// GetCampaign retrieves a campaign. +func (c *Core) GetCampaign(id int, uuid string) (models.Campaign, error) { + // Unsafe to ignore scanning fields not present in models.Campaigns. + var uu interface{} + if uuid != "" { + uu = uuid + } + + var out models.Campaigns + if err := c.q.GetCampaign.Select(&out, id, uu); err != nil { + // if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil { + c.log.Printf("error fetching campaign: %v", err) + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + if len(out) == 0 { + return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) + + } + + for i := 0; i < len(out); i++ { + // Replace null tags. + if out[i].Tags == nil { + out[i].Tags = []string{} + } + } + + // Lazy load stats. + if err := out.LoadStats(c.q.GetCampaignStats); err != nil { + c.log.Printf("error fetching campaign stats: %v", err) + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + return out[0], nil +} + +// GetCampaignForPreview retrieves a campaign with a template body. +func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) { + var out models.Campaign + if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil { + if err == sql.ErrNoRows { + return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) + } + + c.log.Printf("error fetching campaign: %v", err) + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// CreateCampaign creates a new campaign. +func (c *Core) CreateCampaign(o models.Campaign, listIDs []int) (models.Campaign, error) { + uu, err := uuid.NewV4() + if err != nil { + c.log.Printf("error generating UUID: %v", err) + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) + } + + // Insert and read ID. + var newID int + if err := c.q.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, + pq.Array(listIDs), + ); err != nil { + if err == sql.ErrNoRows { + return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs")) + } + + c.log.Printf("error creating campaign: %v", err) + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + out, err := c.GetCampaign(newID, "") + if err != nil { + return models.Campaign{}, err + } + + return out, nil +} + +// UpdateCampaign updates a campaign. +func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, sendLater bool) (models.Campaign, error) { + _, err := c.q.UpdateCampaign.Exec(id, + o.Name, + o.Subject, + o.FromEmail, + o.Body, + o.AltBody, + o.ContentType, + o.SendAt, + sendLater, + o.Headers, + pq.StringArray(normalizeTags(o.Tags)), + o.Messenger, + o.TemplateID, + pq.Array(listIDs)) + if err != nil { + c.log.Printf("error updating campaign: %v", err) + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + out, err := c.GetCampaign(id, "") + if err != nil { + return models.Campaign{}, err + } + + return out, nil +} + +// UpdateCampaignStatus updates a campaign's status, eg: draft to running. +func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) { + cm, err := c.GetCampaign(id, "") + if err != nil { + return models.Campaign{}, err + } + + errMsg := "" + switch status { + case models.CampaignStatusDraft: + if cm.Status != models.CampaignStatusScheduled { + errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft") + } + case models.CampaignStatusScheduled: + if cm.Status != models.CampaignStatusDraft { + errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled") + } + if !cm.SendAt.Valid { + errMsg = c.i18n.T("campaigns.needsSendAt") + } + + case models.CampaignStatusRunning: + if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft { + errMsg = c.i18n.T("campaigns.onlyPausedDraft") + } + case models.CampaignStatusPaused: + if cm.Status != models.CampaignStatusRunning { + errMsg = c.i18n.T("campaigns.onlyActivePause") + } + case models.CampaignStatusCancelled: + if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused { + errMsg = c.i18n.T("campaigns.onlyActiveCancel") + } + } + + if len(errMsg) > 0 { + return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg) + } + + res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status) + if err != nil { + c.log.Printf("error updating campaign status: %v", err) + + return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + if n, _ := res.RowsAffected(); n == 0 { + return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + + cm.Status = status + return cm, nil +} + +// DeleteCampaign deletes a campaign. +func (c *Core) DeleteCampaign(id int) error { + res, err := c.q.DeleteCampaign.Exec(id) + if err != nil { + c.log.Printf("error deleting campaign: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + + } + + if n, _ := res.RowsAffected(); n == 0 { + return echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) + } + + return nil +} + +// GetRunningCampaignStats returns the progress stats of running campaigns. +func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) { + out := []models.CampaignStats{} + if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + + c.log.Printf("error fetching campaign stats: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } else if len(out) == 0 { + return nil, nil + } + + return out, nil +} + +func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) { + // Pick campaign view counts or click counts. + var stmt *sqlx.Stmt + switch typ { + case "views": + stmt = c.q.GetCampaignViewCounts + case "clicks": + stmt = c.q.GetCampaignClickCounts + case "bounces": + stmt = c.q.GetCampaignBounceCounts + default: + return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData")) + } + + if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) { + return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates")) + } + + out := []models.CampaignAnalyticsCount{} + if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil { + c.log.Printf("error fetching campaign %s: %v", typ, err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs. +func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) { + out := []models.CampaignAnalyticsLink{} + if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil { + c.log.Printf("error fetching campaign %s: %v", typ, err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// RegisterCampaignView registers a subscriber's view on a campaign. +func (c *Core) RegisterCampaignView(campUUID, subUUID string) error { + if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil { + c.log.Printf("error registering campaign view: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + } + return nil +} + +// RegisterCampaignLinkClick registers a subscriber's link click on a campaign. +func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) error { + if _, err := c.q.RegisterLinkClick.Exec(linkUUID, campUUID, subUUID); err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" { + return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink")) + } + + c.log.Printf("error registering link click: %s", err) + return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest")) + } + return nil +} diff --git a/internal/core/core.go b/internal/core/core.go new file mode 100644 index 0000000..5ad3015 --- /dev/null +++ b/internal/core/core.go @@ -0,0 +1,150 @@ +// package core is the collection of re-usable functions that primarily provides data (DB / CRUD) operations +// to the app. For instance, creating and mutating objects like lists, subscribers etc. +// All such methods return an echo.HTTPError{} (which implements error.error) that can be directly returned +// as a response to HTTP handlers without further processing. +package core + +import ( + "bytes" + "fmt" + "log" + "regexp" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/knadh/listmonk/internal/i18n" + "github.com/knadh/listmonk/models" + "github.com/lib/pq" +) + +const ( + SortAsc = "asc" + SortDesc = "desc" +) + +// Core represents the listmonk core with all shared, global functions. +type Core struct { + h *Hooks + + constants Constants + i18n *i18n.I18n + db *sqlx.DB + q *models.Queries + log *log.Logger +} + +// Constants represents constant config. +type Constants struct { + SendOptinConfirmation bool +} + +// Hooks contains external function hooks that are required by the core package. +type Hooks struct { + SendOptinConfirmation func(models.Subscriber, []int) (int, error) +} + +// Opt contains the controllers required to start the core. +type Opt struct { + Constants Constants + I18n *i18n.I18n + DB *sqlx.DB + Queries *models.Queries + Log *log.Logger +} + +var ( + regexFullTextQuery = regexp.MustCompile(`\s+`) + regexpSpaces = regexp.MustCompile(`[\s]+`) + querySortFields = []string{"name", "status", "created_at", "updated_at"} +) + +// New returns a new instance of the core. +func New(o *Opt, h *Hooks) *Core { + return &Core{ + h: h, + constants: o.Constants, + i18n: o.I18n, + db: o.DB, + q: o.Queries, + log: o.Log, + } +} + +// Given an error, pqErrMsg will try to return pq error details +// if it's a pq error. +func pqErrMsg(err error) string { + if err, ok := err.(*pq.Error); ok { + if err.Detail != "" { + return fmt.Sprintf("%s. %s", err, err.Detail) + } + } + return err.Error() +} + +// 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(searchStr, orderBy, order, query string) (string, string) { + if searchStr != "" { + searchStr = `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%` + } + + // Sort params. + if !strSliceContains(orderBy, querySortFields) { + orderBy = "created_at" + } + if order != SortAsc && order != SortDesc { + order = SortDesc + } + + return searchStr, fmt.Sprintf(query, orderBy, order) +} + +// strSliceContains checks if a string is present in the string slice. +func strSliceContains(str string, sl []string) bool { + for _, s := range sl { + if s == str { + return true + } + } + + return false +} + +// normalizeTags takes a list of string tags and normalizes them by +// lower casing and removing all special characters except for dashes. +func normalizeTags(tags []string) []string { + var ( + out []string + dash = []byte("-") + ) + + for _, t := range tags { + rep := regexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash) + + if len(rep) > 0 { + out = append(out, string(rep)) + } + } + return out +} + +// sanitizeSQLExp does basic sanitisation on arbitrary +// SQL query expressions coming from the frontend. +func sanitizeSQLExp(q string) string { + if len(q) == 0 { + return "" + } + q = strings.TrimSpace(q) + + // Remove semicolon suffix. + if q[len(q)-1] == ';' { + q = q[:len(q)-1] + } + return q +} + +// strHasLen checks if the given string has a length within min-max. +func strHasLen(str string, min, max int) bool { + return len(str) >= min && len(str) <= max +} diff --git a/internal/core/dashboard.go b/internal/core/dashboard.go new file mode 100644 index 0000000..ee0374a --- /dev/null +++ b/internal/core/dashboard.go @@ -0,0 +1,30 @@ +package core + +import ( + "net/http" + + "github.com/jmoiron/sqlx/types" + "github.com/labstack/echo/v4" +) + +// GetDashboardCharts returns chart data points to render on the dashboard. +func (c *Core) GetDashboardCharts() (types.JSONText, error) { + var out types.JSONText + if err := c.q.GetDashboardCharts.Get(&out); err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err))) + } + + return out, nil +} + +// GetDashboardCounts returns stats counts to show on the dashboard. +func (c *Core) GetDashboardCounts() (types.JSONText, error) { + var out types.JSONText + if err := c.q.GetDashboardCounts.Get(&out); err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err))) + } + + return out, nil +} diff --git a/internal/core/lists.go b/internal/core/lists.go new file mode 100644 index 0000000..22cc363 --- /dev/null +++ b/internal/core/lists.go @@ -0,0 +1,133 @@ +package core + +import ( + "net/http" + + "github.com/gofrs/uuid" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "github.com/lib/pq" +) + +// GetLists gets all lists optionally filtered by type. +func (c *Core) GetLists(typ string) ([]models.List, error) { + out := []models.List{} + + // TODO: remove orderBy + if err := c.q.GetLists.Select(&out, typ, "id"); err != nil { + c.log.Printf("error fetching lists: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// QueryLists gets multiple lists based on multiple query params. +func (c *Core) QueryLists(searchStr, orderBy, order string, offset, limit int) ([]models.List, error) { + out := []models.List{} + + queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists) + + if err := c.db.Select(&out, stmt, 0, "", queryStr, offset, limit); err != nil { + c.log.Printf("error fetching lists: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// GetList gets a list by its ID or UUID. +func (c *Core) GetList(id int, uuid string) (models.List, error) { + var uu interface{} + if uuid != "" { + uu = uuid + } + + var out []models.List + queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists) + if err := c.db.Select(&out, stmt, id, uu, queryStr, 0, 1); err != nil { + c.log.Printf("error fetching lists: %v", err) + return models.List{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + } + + if len(out) == 1 { + return out[0], nil + } + + return models.List{}, nil +} + +// GetListsByOptin returns lists by optin type. +func (c *Core) GetListsByOptin(ids []int, optinType string) ([]models.List, error) { + out := []models.List{} + if err := c.q.GetListsByOptin.Select(&out, optinType, pq.Array(ids), nil); err != nil { + c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// CreateList creates a new list. +func (c *Core) CreateList(l models.List) (models.List, error) { + uu, err := uuid.NewV4() + if err != nil { + c.log.Printf("error generating UUID: %v", err) + return models.List{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) + } + + if l.Type == "" { + l.Type = models.ListTypePrivate + } + if l.Optin == "" { + l.Optin = models.ListOptinSingle + } + + // Insert and read ID. + var newID int + l.UUID = uu.String() + if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags))); err != nil { + c.log.Printf("error creating list: %v", err) + return models.List{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err))) + } + + return c.GetList(newID, "") +} + +// UpdateList updates a given list. +func (c *Core) UpdateList(id int, l models.List) (models.List, error) { + res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags))) + if err != nil { + c.log.Printf("error updating list: %v", err) + return models.List{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.list}", "error", pqErrMsg(err))) + } + + if n, _ := res.RowsAffected(); n == 0 { + return models.List{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) + } + + return c.GetList(id, "") +} + +// DeleteList deletes a list. +func (c *Core) DeleteList(id int) error { + return c.DeleteLists([]int{id}) +} + +// DeleteLists deletes multiple lists. +func (c *Core) DeleteLists(ids []int) error { + if _, err := c.q.DeleteLists.Exec(pq.Array(ids)); err != nil { + c.log.Printf("error deleting lists: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err))) + } + return nil +} diff --git a/internal/core/media.go b/internal/core/media.go new file mode 100644 index 0000000..999ad9a --- /dev/null +++ b/internal/core/media.go @@ -0,0 +1,77 @@ +package core + +import ( + "net/http" + + "github.com/gofrs/uuid" + "github.com/knadh/listmonk/internal/media" + "github.com/labstack/echo/v4" +) + +// GetAllMedia returns all uploaded media. +func (c *Core) GetAllMedia(provider string, s media.Store) ([]media.Media, error) { + out := []media.Media{} + if err := c.q.GetAllMedia.Select(&out, provider); err != nil { + return out, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.media}", "error", pqErrMsg(err))) + } + + for i := 0; i < len(out); i++ { + out[i].URL = s.Get(out[i].Filename) + out[i].ThumbURL = s.Get(out[i].Thumb) + } + + return out, nil +} + +// GetMedia returns a media item. +func (c *Core) GetMedia(id int, uuid string, s media.Store) (media.Media, error) { + var uu interface{} + if uuid != "" { + uu = uuid + } + + var out media.Media + if err := c.q.GetMedia.Get(&out, id, uu); err != nil { + return out, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}", "error", pqErrMsg(err))) + } + + out.URL = s.Get(out.Filename) + out.ThumbURL = s.Get(out.Thumb) + + return out, nil +} + +// InsertMedia inserts a new media file into the DB. +func (c *Core) InsertMedia(fileName, thumbName string, provider string, s media.Store) (media.Media, error) { + uu, err := uuid.NewV4() + if err != nil { + c.log.Printf("error generating UUID: %v", err) + return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) + } + + // Write to the DB. + var newID int + if err := c.q.InsertMedia.Get(&newID, uu, fileName, thumbName, provider); err != nil { + c.log.Printf("error inserting uploaded file to db: %v", err) + return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err))) + } + + return c.GetMedia(newID, "", s) +} + +// DeleteMedia deletes a given media item and returns the filename of the deleted item. +func (c *Core) DeleteMedia(id int) (string, error) { + var fname string + if err := c.q.DeleteMedia.Get(&fname, id); err != nil { + c.log.Printf("error inserting uploaded file to db: %v", err) + return "", echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err))) + } + + return fname, nil +} diff --git a/internal/core/settings.go b/internal/core/settings.go new file mode 100644 index 0000000..c3dec64 --- /dev/null +++ b/internal/core/settings.go @@ -0,0 +1,50 @@ +package core + +import ( + "encoding/json" + "net/http" + + "github.com/jmoiron/sqlx/types" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// GetSettings returns settings from the DB. +func (c *Core) GetSettings() (models.Settings, error) { + var ( + b types.JSONText + out models.Settings + ) + + if err := c.q.GetSettings.Get(&b); err != nil { + return out, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.settings}", "error", pqErrMsg(err))) + } + + // Unmarshal the settings and filter out sensitive fields. + if err := json.Unmarshal([]byte(b), &out); err != nil { + return out, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("settings.errorEncoding", "error", err.Error())) + } + + return out, nil +} + +// UpdateSettings updates settings. +func (c *Core) UpdateSettings(s models.Settings) error { + // Marshal settings. + b, err := json.Marshal(s) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("settings.errorEncoding", "error", err.Error())) + } + + // Update the settings in the DB. + if _, err := c.q.UpdateSettings.Exec(b); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err))) + } + + return nil +} diff --git a/internal/core/subscribers.go b/internal/core/subscribers.go new file mode 100644 index 0000000..dcb4b23 --- /dev/null +++ b/internal/core/subscribers.go @@ -0,0 +1,436 @@ +package core + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gofrs/uuid" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "github.com/lib/pq" +) + +var ( + subQuerySortFields = []string{"email", "name", "created_at", "updated_at"} +) + +// GetSubscriber fetches a subscriber by one of the given params. +func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, error) { + var uu interface{} + if uuid != "" { + uu = uuid + } + + var out models.Subscribers + if err := c.q.GetSubscriber.Select(&out, id, uu, email); err != nil { + c.log.Printf("error fetching subscriber: %v", err) + return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) + } + if len(out) == 0 { + return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}")) + } + if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil { + c.log.Printf("error loading subscriber lists: %v", err) + return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + } + + return out[0], nil +} + +// GetSubscribersByEmail fetches a subscriber by one of the given params. +func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscribers, error) { + var out models.Subscribers + + if err := c.q.GetSubscribersByEmails.Select(&out, pq.Array(emails)); err != nil { + c.log.Printf("error fetching subscriber: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) + } + if len(out) == 0 { + return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noKnownSubsToTest")) + } + + if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil { + c.log.Printf("error loading subscriber lists: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// QuerySubscribers queries and returns paginated subscrribers based on the given params including the total count. +func (c *Core) QuerySubscribers(query string, listIDs []int, order, orderBy string, offset, limit int) (models.Subscribers, int, error) { + // There's an arbitrary query condition. + cond := "" + if query != "" { + cond = " AND " + query + } + + // Sort params. + if !strSliceContains(orderBy, subQuerySortFields) { + orderBy = "subscribers.id" + } + if order != SortAsc && order != SortDesc { + order = SortDesc + } + + // Required for pq.Array() + if listIDs == nil { + listIDs = []int{} + } + + // Create a readonly transaction that just does COUNT() to obtain the count of results + // and to ensure that the arbitrary query is indeed readonly. + stmt := fmt.Sprintf(c.q.QuerySubscribersCount, cond) + tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) + if err != nil { + c.log.Printf("error preparing subscriber query: %v", err) + return nil, 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + defer tx.Rollback() + + // Execute the readonly query and get the count of results. + total := 0 + if err := tx.Get(&total, stmt, pq.Array(listIDs)); err != nil { + return nil, 0, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + // No results. + if total == 0 { + return models.Subscribers{}, 0, nil + } + + // Run the query again and fetch the actual data. stmt is the raw SQL query. + var out models.Subscribers + stmt = fmt.Sprintf(c.q.QuerySubscribers, cond, orderBy, order) + if err := tx.Select(&out, stmt, pq.Array(listIDs), offset, limit); err != nil { + return nil, 0, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + // Lazy load lists for each subscriber. + if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil { + c.log.Printf("error fetching subscriber lists: %v", err) + return nil, 0, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return out, total, nil +} + +// GetSubscriberLists returns a subscriber's lists based on the given conditions. +func (c *Core) GetSubscriberLists(subID int, uuid string, listIDs []int, listUUIDs []string, subStatus string, listType string) ([]models.List, error) { + if listIDs == nil { + listIDs = []int{} + } + if listUUIDs == nil { + listUUIDs = []string{} + } + + var uu interface{} + if uuid != "" { + uu = uuid + } + + // Fetch double opt-in lists from the given list IDs. + // Get the list of subscription lists where the subscriber hasn't confirmed. + out := []models.List{} + if err := c.q.GetSubscriberLists.Select(&out, subID, uu, pq.Array(listIDs), pq.Array(listUUIDs), subStatus, listType); err != nil { + c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) + return nil, err + } + + return out, nil +} + +// GetSubscriberProfileForExport returns the subscriber's profile data as a JSON exportable. +// Get the subscriber's data. A single query that gets the profile, list subscriptions, campaign views, +// and link clicks. Names of private lists are replaced with "Private list". +func (c *Core) GetSubscriberProfileForExport(id int, uuid string) (models.SubscriberExportProfile, error) { + var uu interface{} + if uuid != "" { + uu = uuid + } + + var out models.SubscriberExportProfile + if err := c.q.ExportSubscriberData.Get(&out, id, uu); err != nil { + c.log.Printf("error fetching subscriber export data: %v", err) + + return models.SubscriberExportProfile{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error())) + } + + return out, nil +} + +// ExportSubscribers returns an iterator function that provides lists of subscribers based +// on the given criteria in an exportable form. The iterator function returned can be called +// repeatedly until there are nil subscribers. It's an iterator because exports can be extremely +// large and may have to be fetched in batches from the DB and streamed somewhere. +func (c *Core) ExportSubscribers(query string, subIDs, listIDs []int, batchSize int) (func() ([]models.SubscriberExport, error), error) { + // There's an arbitrary query condition. + cond := "" + if query != "" { + cond = " AND " + query + } + + stmt := fmt.Sprintf(c.q.QuerySubscribersForExport, cond) + + // Verify that the arbitrary SQL search expression is read only. + if cond != "" { + tx, err := c.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) + if err != nil { + c.log.Printf("error preparing subscriber query: %v", err) + return nil, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + defer tx.Rollback() + + if _, err := tx.Query(stmt, nil, 0, nil, 1); err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + } + + if subIDs == nil { + subIDs = []int{} + } + if listIDs == nil { + listIDs = []int{} + } + + // Prepare the actual query statement. + tx, err := c.db.Preparex(stmt) + if err != nil { + c.log.Printf("error preparing subscriber query: %v", err) + return nil, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + } + + id := 0 + return func() ([]models.SubscriberExport, error) { + var out []models.SubscriberExport + if err := tx.Select(&out, pq.Array(listIDs), id, pq.Array(subIDs), batchSize); err != nil { + c.log.Printf("error exporting subscribers by query: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + if len(out) == 0 { + return nil, nil + } + + id = out[len(out)-1].ID + return out, nil + }, nil +} + +// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if +// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation. +// 1st bool = isNew?, 2nd bool = optinSent? +func (c *Core) CreateSubscriber(sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm bool) (models.Subscriber, bool, bool, error) { + uu, err := uuid.NewV4() + if err != nil { + c.log.Printf("error generating UUID: %v", err) + return models.Subscriber{}, false, false, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) + } + sub.UUID = uu.String() + + var ( + isNew = true + subStatus = models.SubscriptionStatusUnconfirmed + ) + + if preconfirm { + subStatus = models.SubscriptionStatusConfirmed + } + if sub.Status == "" { + sub.Status = models.UserStatusEnabled + } + + // For pq.Array() + if listIDs == nil { + listIDs = []int{} + } + if listUUIDs == nil { + listUUIDs = []string{} + } + + if err = c.q.InsertSubscriber.Get(&sub.ID, + sub.UUID, + sub.Email, + strings.TrimSpace(sub.Name), + sub.Status, + sub.Attribs, + pq.Array(listIDs), + pq.Array(listUUIDs), + subStatus); err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { + isNew = false + } else { + // return sub.Subscriber, errSubscriberExists + c.log.Printf("error inserting subscriber: %v", err) + return models.Subscriber{}, false, false, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) + } + } + + // Fetch the subscriber'out full data. If the subscriber already existed and wasn't + // created, the id will be empty. Fetch the details by e-mail then. + out, err := c.GetSubscriber(sub.ID, "", sub.Email) + if err != nil { + return models.Subscriber{}, false, false, err + } + + hasOptin := false + if !preconfirm && c.constants.SendOptinConfirmation { + // Send a confirmation e-mail (if there are any double opt-in lists). + num, _ := c.h.SendOptinConfirmation(out, listIDs) + hasOptin = num > 0 + } + + return out, isNew, hasOptin, nil +} + +// UpdateSubscriber updates a subscriber's properties. +func (c *Core) UpdateSubscriber(id int, sub models.Subscriber, listIDs []int, preconfirm bool) (models.Subscriber, error) { + subStatus := models.SubscriptionStatusUnconfirmed + if preconfirm { + subStatus = models.SubscriptionStatusConfirmed + } + + // Format raw JSON attributes. + attribs := []byte("{}") + if len(sub.Attribs) > 0 { + if b, err := json.Marshal(sub.Attribs); err != nil { + return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscriber}", "error", err.Error())) + } else { + attribs = b + } + } + + _, err := c.q.UpdateSubscriber.Exec(id, + sub.Email, + strings.TrimSpace(sub.Name), + sub.Status, + json.RawMessage(attribs), + pq.Array(listIDs), + subStatus) + if err != nil { + c.log.Printf("error updating subscriber: %v", err) + return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", + "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) + } + + out, err := c.GetSubscriber(sub.ID, "", sub.Email) + if err != nil { + return models.Subscriber{}, err + } + + return out, nil +} + +// BlocklistSubscribers blocklists the given list of subscribers. +func (c *Core) BlocklistSubscribers(subIDs []int) error { + if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil { + c.log.Printf("error blocklisting subscribers: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error())) + } + + return nil +} + +// BlocklistSubscribersByQuery blocklists the given list of subscribers. +func (c *Core) BlocklistSubscribersByQuery(query string, listIDs []int) error { + if err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.BlocklistSubscribersByQuery, listIDs, c.db); err != nil { + c.log.Printf("error blocklisting subscribers: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err))) + } + + return nil +} + +// DeleteSubscribers deletes the given list of subscribers. +func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error { + if subIDs == nil { + subIDs = []int{} + } + if subUUIDs == nil { + subUUIDs = []string{} + } + + if _, err := c.q.DeleteSubscribers.Exec(pq.Array(subIDs), pq.Array(subUUIDs)); err != nil { + c.log.Printf("error deleting subscribers: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return nil +} + +// DeleteSubscribersByQuery deletes subscribers by a given arbitrary query expression. +func (c *Core) DeleteSubscribersByQuery(query string, listIDs []int) error { + err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscribersByQuery, listIDs, c.db) + if err != nil { + c.log.Printf("error deleting subscribers: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return err +} + +// UnsubscribeByCampaign unsubscibers a given subscriber from lists in a given campaign. +func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist bool) error { + if _, err := c.q.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil { + c.log.Printf("error unsubscribing: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return nil +} + +// ConfirmOptionSubscription confirms a subscriber's optin subscription. +func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string) error { + if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs)); err != nil { + c.log.Printf("error confirming subscription: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return nil +} + +// DeleteSubscriberBounces deletes the given list of subscribers. +func (c *Core) DeleteSubscriberBounces(id int, uuid string) error { + var uu interface{} + if uuid != "" { + uu = uuid + } + + if _, err := c.q.DeleteBouncesBySubscriber.Exec(id, uu); err != nil { + c.log.Printf("error deleting bounces: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.bounces}", "error", pqErrMsg(err))) + } + + return nil +} diff --git a/internal/core/subscriptions.go b/internal/core/subscriptions.go new file mode 100644 index 0000000..52cfef3 --- /dev/null +++ b/internal/core/subscriptions.go @@ -0,0 +1,93 @@ +package core + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/lib/pq" +) + +// AddSubscriptions adds list subscriptions to subscribers. +func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error { + if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil { + c.log.Printf("error adding subscriptions: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error())) + } + + return nil +} + +// AddSubscriptionsByQuery adds list subscriptions to subscribers by a given arbitrary query expression. +// sourceListIDs is the list of list IDs to filter the subscriber query with. +func (c *Core) AddSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int) error { + if sourceListIDs == nil { + sourceListIDs = []int{} + } + + err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, targetListIDs) + if err != nil { + c.log.Printf("error adding subscriptions by query: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return nil +} + +// DeleteSubscriptions delete list subscriptions from subscribers. +func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error { + if _, err := c.q.DeleteSubscriptions.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil { + c.log.Printf("error deleting subscriptions: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error())) + + } + + return nil +} + +// DeleteSubscriptionsByQuery deletes list subscriptions from subscribers by a given arbitrary query expression. +// sourceListIDs is the list of list IDs to filter the subscriber query with. +func (c *Core) DeleteSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int) error { + if sourceListIDs == nil { + sourceListIDs = []int{} + } + + err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, targetListIDs) + if err != nil { + c.log.Printf("error deleting subscriptions by query: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return nil +} + +// UnsubscribeLists sets list subscriptions to 'unsubscribed'. +func (c *Core) UnsubscribeLists(subIDs, listIDs []int) error { + if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil { + c.log.Printf("error unsubscribing from lists: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error())) + } + + return nil +} + +// UnsubscribeListsByQuery sets list subscriptions to 'ubsubscribed' by a given arbitrary query expression. +// sourceListIDs is the list of list IDs to filter the subscriber query with. +func (c *Core) UnsubscribeListsByQuery(query string, sourceListIDs, targetListIDs []int) error { + if sourceListIDs == nil { + sourceListIDs = []int{} + } + + err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, targetListIDs) + if err != nil { + c.log.Printf("error unsubscriging from lists by query: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + } + + return nil +} diff --git a/internal/core/templates.go b/internal/core/templates.go new file mode 100644 index 0000000..baafbf9 --- /dev/null +++ b/internal/core/templates.go @@ -0,0 +1,87 @@ +package core + +import ( + "net/http" + + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// GetTemplates retrieves all templates. +func (c *Core) GetTemplates(noBody bool) ([]models.Template, error) { + out := []models.Template{} + if err := c.q.GetTemplates.Select(&out, 0, noBody); err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err))) + } + + return out, nil +} + +// GetTemplate retrieves a given template. +func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) { + var out []models.Template + if err := c.q.GetTemplates.Select(&out, id, noBody); err != nil { + return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err))) + } + + if len(out) == 0 { + return models.Template{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) + } + + return out[0], nil +} + +// CreateTemplate creates a new template. +func (c *Core) CreateTemplate(name string, body []byte) (models.Template, error) { + var newID int + if err := c.q.CreateTemplate.Get(&newID, name, body); err != nil { + return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err))) + } + + return c.GetTemplate(newID, false) +} + +// UpdateTemplate updates a given template. +func (c *Core) UpdateTemplate(id int, name string, body []byte) (models.Template, error) { + res, err := c.q.UpdateTemplate.Exec(id, name, body) + if err != nil { + return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err))) + } + + if n, _ := res.RowsAffected(); n == 0 { + return models.Template{}, echo.NewHTTPError(http.StatusBadRequest, + c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) + } + + return c.GetTemplate(id, false) +} + +// SetDefaultTemplate sets a template as default. +func (c *Core) SetDefaultTemplate(id int) error { + if _, err := c.q.SetDefaultTemplate.Exec(id); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err))) + } + + return nil +} + +// DeleteTemplate deletes a given template. +func (c *Core) DeleteTemplate(id int) error { + var delID int + if err := c.q.DeleteTemplate.Get(&delID, id); err != nil { + // TODO: Fix this. Deletes but always throws a "no result set" error. + return echo.NewHTTPError(http.StatusInternalServerError, + c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.template}", "error", pqErrMsg(err))) + } + if delID == 0 { + return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("templates.cantDeleteDefault")) + } + + return nil +} diff --git a/internal/subimporter/importer.go b/internal/subimporter/importer.go index 3bb20be..3794f41 100644 --- a/internal/subimporter/importer.go +++ b/internal/subimporter/importer.go @@ -527,7 +527,7 @@ func (s *Session) LoadCSV(srcPath string, delim rune) error { sub.Email = row["email"] sub.Name = row["name"] - sub, err = s.im.ValidateFields(sub) + sub, err = s.im.validateFields(sub) if err != nil { s.log.Printf("skipping line %d: %s: %v", i, sub.Email, err) continue @@ -571,26 +571,6 @@ func (im *Importer) Stop() { } } -// ValidateFields validates incoming subscriber field values and returns sanitized fields. -func (im *Importer) ValidateFields(s SubReq) (SubReq, error) { - if len(s.Email) > 1000 { - return s, errors.New(im.i18n.T("subscribers.invalidEmail")) - } - - s.Name = strings.TrimSpace(s.Name) - if len(s.Name) == 0 || len(s.Name) > stdInputMaxLen { - return s, errors.New(im.i18n.T("subscribers.invalidName")) - } - - em, err := im.SanitizeEmail(s.Email) - if err != nil { - return s, err - } - s.Email = em - - return s, nil -} - // SanitizeEmail validates and sanitizes an e-mail string and returns the lowercased, // e-mail component of an e-mail string. func (im *Importer) SanitizeEmail(email string) (string, error) { @@ -616,6 +596,26 @@ func (im *Importer) SanitizeEmail(email string) (string, error) { return em.Address, nil } +// validateFields validates incoming subscriber field values and returns sanitized fields. +func (im *Importer) validateFields(s SubReq) (SubReq, error) { + if len(s.Email) > 1000 { + return s, errors.New(im.i18n.T("subscribers.invalidEmail")) + } + + s.Name = strings.TrimSpace(s.Name) + if len(s.Name) == 0 || len(s.Name) > stdInputMaxLen { + return s, errors.New(im.i18n.T("subscribers.invalidName")) + } + + em, err := im.SanitizeEmail(s.Email) + if err != nil { + return s, err + } + s.Email = strings.ToLower(em) + + return s, nil +} + // mapCSVHeaders takes a list of headers obtained from a CSV file, a map of known headers, // and returns a new map with each of the headers in the known map mapped by the position (0-n) // in the given CSV list. diff --git a/models/models.go b/models/models.go index ea22755..d45c833 100644 --- a/models/models.go +++ b/models/models.go @@ -121,6 +121,16 @@ var regTplFuncs = []regTplFunc{ // when a campaign's status changes. type AdminNotifCallback func(subject string, data interface{}) error +// PageResults is a generic HTTP response container for paginated results of list of items. +type PageResults struct { + Results interface{} `json:"results"` + + Query string `json:"query"` + Total int `json:"total"` + PerPage int `json:"per_page"` + Page int `json:"page"` +} + // Base holds common fields shared across models. type Base struct { ID int `db:"id" json:"id"` @@ -155,6 +165,15 @@ type subLists struct { Lists types.JSONText `db:"lists"` } +// SubscriberExportProfile represents a subscriber's collated data in JSON for export. +type SubscriberExportProfile struct { + Email string `db:"email" json:"-"` + Profile json.RawMessage `db:"profile" json:"profile,omitempty"` + Subscriptions json.RawMessage `db:"subscriptions" json:"subscriptions,omitempty"` + CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views,omitempty"` + LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"` +} + // SubscriberAttribs is the map of key:value attributes of a subscriber. type SubscriberAttribs map[string]interface{} @@ -246,6 +265,28 @@ type CampaignMeta struct { Sent int `db:"sent" json:"sent"` } +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 CampaignAnalyticsCount struct { + CampaignID int `db:"campaign_id" json:"campaign_id"` + Count int `db:"count" json:"count"` + Timestamp time.Time `db:"timestamp" json:"timestamp"` +} + +type CampaignAnalyticsLink struct { + URL string `db:"url" json:"url"` + Count int `db:"count" json:"count"` +} + // Campaigns represents a slice of Campaigns. type Campaigns []Campaign diff --git a/cmd/queries.go b/models/queries.go similarity index 80% rename from cmd/queries.go rename to models/queries.go index e897fe1..ee4bef8 100644 --- a/cmd/queries.go +++ b/models/queries.go @@ -1,10 +1,9 @@ -package main +package models import ( "context" "database/sql" "fmt" - "time" "github.com/jmoiron/sqlx" "github.com/lib/pq" @@ -22,7 +21,6 @@ type Queries struct { GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"` - SubscriberExists *sqlx.Stmt `query:"subscriber-exists"` UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"` BlocklistSubscribers *sqlx.Stmt `query:"blocklist-subscribers"` AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"` @@ -72,6 +70,7 @@ type Queries struct { DeleteCampaign *sqlx.Stmt `query:"delete-campaign"` InsertMedia *sqlx.Stmt `query:"insert-media"` + GetAllMedia *sqlx.Stmt `query:"get-all-media"` GetMedia *sqlx.Stmt `query:"get-media"` DeleteMedia *sqlx.Stmt `query:"delete-media"` @@ -94,39 +93,12 @@ type Queries struct { DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"` } -// dbConf contains database config required for connecting to a DB. -type dbConf struct { - Host string `koanf:"host"` - Port int `koanf:"port"` - User string `koanf:"user"` - Password string `koanf:"password"` - DBName string `koanf:"database"` - SSLMode string `koanf:"ssl_mode"` - MaxOpen int `koanf:"max_open"` - MaxIdle int `koanf:"max_idle"` - MaxLifetime time.Duration `koanf:"max_lifetime"` -} - -// connectDB initializes a database connection. -func connectDB(c dbConf) (*sqlx.DB, error) { - db, err := sqlx.Connect("postgres", - fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode)) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(c.MaxOpen) - db.SetMaxIdleConns(c.MaxIdle) - db.SetConnMaxLifetime(c.MaxLifetime) - return db, nil -} - -// compileSubscriberQueryTpl takes an arbitrary WHERE expressions +// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions // to filter subscribers from the subscribers table and prepares a query // out of it using the raw `query-subscribers-template` query template. // While doing this, a readonly transaction is created and the query is // dry run on it to ensure that it is indeed readonly. -func (q *Queries) compileSubscriberQueryTpl(exp string, db *sqlx.DB) (string, error) { +func (q *Queries) CompileSubscriberQueryTpl(exp string, db *sqlx.DB) (string, error) { tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) if err != nil { return "", err @@ -148,19 +120,19 @@ func (q *Queries) compileSubscriberQueryTpl(exp string, db *sqlx.DB) (string, er // compileSubscriberQueryTpl takes an arbitrary WHERE expressions and a subscriber // query template that depends on the filter (eg: delete by query, blocklist by query etc.) // combines and executes them. -func (q *Queries) execSubscriberQueryTpl(exp, tpl string, listIDs []int64, db *sqlx.DB, args ...interface{}) error { +func (q *Queries) ExecSubscriberQueryTpl(exp, tpl string, listIDs []int, db *sqlx.DB, args ...interface{}) error { // Perform a dry run. - filterExp, err := q.compileSubscriberQueryTpl(exp, db) + filterExp, err := q.CompileSubscriberQueryTpl(exp, db) if err != nil { return err } if len(listIDs) == 0 { - listIDs = pq.Int64Array{} + listIDs = []int{} } // First argument is the boolean indicating if the query is a dry run. - a := append([]interface{}{false, pq.Int64Array(listIDs)}, args...) + a := append([]interface{}{false, pq.Array(listIDs)}, args...) if _, err := db.Exec(fmt.Sprintf(tpl, filterExp), a...); err != nil { return err } diff --git a/models/settings.go b/models/settings.go new file mode 100644 index 0000000..aed4996 --- /dev/null +++ b/models/settings.go @@ -0,0 +1,102 @@ +package models + +// Settings represents the app settings stored in the DB. +type Settings struct { + AppRootURL string `json:"app.root_url"` + AppLogoURL string `json:"app.logo_url"` + AppFaviconURL string `json:"app.favicon_url"` + AppFromEmail string `json:"app.from_email"` + AppNotifyEmails []string `json:"app.notify_emails"` + EnablePublicSubPage bool `json:"app.enable_public_subscription_page"` + SendOptinConfirmation bool `json:"app.send_optin_confirmation"` + CheckUpdates bool `json:"app.check_updates"` + AppLang string `json:"app.lang"` + + AppBatchSize int `json:"app.batch_size"` + AppConcurrency int `json:"app.concurrency"` + AppMaxSendErrors int `json:"app.max_send_errors"` + AppMessageRate int `json:"app.message_rate"` + + AppMessageSlidingWindow bool `json:"app.message_sliding_window"` + AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"` + AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"` + + PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` + PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` + PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` + PrivacyAllowExport bool `json:"privacy.allow_export"` + PrivacyAllowWipe bool `json:"privacy.allow_wipe"` + PrivacyExportable []string `json:"privacy.exportable"` + DomainBlocklist []string `json:"privacy.domain_blocklist"` + + UploadProvider string `json:"upload.provider"` + UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"` + UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"` + UploadS3URL string `json:"upload.s3.url"` + UploadS3PublicURL string `json:"upload.s3.public_url"` + UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"` + UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"` + UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"` + UploadS3Bucket string `json:"upload.s3.bucket"` + UploadS3BucketDomain string `json:"upload.s3.bucket_domain"` + UploadS3BucketPath string `json:"upload.s3.bucket_path"` + UploadS3BucketType string `json:"upload.s3.bucket_type"` + UploadS3Expiry string `json:"upload.s3.expiry"` + + SMTP []struct { + UUID string `json:"uuid"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + HelloHostname string `json:"hello_hostname"` + Port int `json:"port"` + AuthProtocol string `json:"auth_protocol"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + EmailHeaders []map[string]string `json:"email_headers"` + MaxConns int `json:"max_conns"` + MaxMsgRetries int `json:"max_msg_retries"` + IdleTimeout string `json:"idle_timeout"` + WaitTimeout string `json:"wait_timeout"` + TLSType string `json:"tls_type"` + TLSSkipVerify bool `json:"tls_skip_verify"` + } `json:"smtp"` + + Messengers []struct { + UUID string `json:"uuid"` + Enabled bool `json:"enabled"` + Name string `json:"name"` + RootURL string `json:"root_url"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + MaxConns int `json:"max_conns"` + Timeout string `json:"timeout"` + MaxMsgRetries int `json:"max_msg_retries"` + } `json:"messengers"` + + BounceEnabled bool `json:"bounce.enabled"` + BounceEnableWebhooks bool `json:"bounce.webhooks_enabled"` + BounceCount int `json:"bounce.count"` + BounceAction string `json:"bounce.action"` + SESEnabled bool `json:"bounce.ses_enabled"` + SendgridEnabled bool `json:"bounce.sendgrid_enabled"` + SendgridKey string `json:"bounce.sendgrid_key"` + BounceBoxes []struct { + UUID string `json:"uuid"` + Enabled bool `json:"enabled"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + AuthProtocol string `json:"auth_protocol"` + ReturnPath string `json:"return_path"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + TLSEnabled bool `json:"tls_enabled"` + TLSSkipVerify bool `json:"tls_skip_verify"` + ScanInterval string `json:"scan_interval"` + } `json:"bounce.mailboxes"` + + AdminCustomCSS string `json:"appearance.admin.custom_css"` + AdminCustomJS string `json:"appearance.admin.custom_js"` + PublicCustomCSS string `json:"appearance.public.custom_css"` + PublicCustomJS string `json:"appearance.public.custom_js"` +} diff --git a/queries.sql b/queries.sql index 85676e9..3646a7b 100644 --- a/queries.sql +++ b/queries.sql @@ -9,10 +9,6 @@ SELECT * FROM subscribers WHERE WHEN $3 != '' THEN email = $3 END; --- name: subscriber-exists --- Check if a subscriber exists by id or UUID. -SELECT exists (SELECT true FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END); - -- name: get-subscribers-by-emails -- Get subscribers by emails. SELECT * FROM subscribers WHERE email=ANY($1); @@ -25,8 +21,8 @@ SELECT * FROM lists LEFT JOIN subscriber_lists ON (lists.id = subscriber_lists.list_id) WHERE subscriber_id = (SELECT id FROM sub) -- Optional list IDs or UUIDs to filter. - AND (CASE WHEN $3::INT[] IS NOT NULL THEN id = ANY($3::INT[]) - WHEN $4::UUID[] IS NOT NULL THEN uuid = ANY($4::UUID[]) + AND (CASE WHEN CARDINALITY($3::INT[]) > 0 THEN id = ANY($3::INT[]) + WHEN CARDINALITY($4::UUID[]) > 0 THEN uuid = ANY($4::UUID[]) ELSE TRUE END) AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status END) @@ -64,7 +60,7 @@ WITH sub AS ( ), listIDs AS ( SELECT id FROM lists WHERE - (CASE WHEN ARRAY_LENGTH($6::INT[], 1) > 0 THEN id=ANY($6) + (CASE WHEN CARDINALITY($6::INT[]) > 0 THEN id=ANY($6) ELSE uuid=ANY($7::UUID[]) END) ), subs AS ( @@ -348,8 +344,14 @@ SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type EN -- name: query-lists WITH ls AS ( SELECT COUNT(*) OVER () AS total, lists.* FROM lists - WHERE ($1 = 0 OR id = $1) AND ($2 = '' OR name ILIKE $2) - OFFSET $3 LIMIT (CASE WHEN $4 = 0 THEN NULL ELSE $4 END) + WHERE + CASE + WHEN $1 > 0 THEN id = $1 + WHEN $2 != '' THEN uuid = $2::UUID + WHEN $3 != '' THEN name ILIKE $3 + ELSE true + END + OFFSET $4 LIMIT (CASE WHEN $5 = 0 THEN NULL ELSE $5 END) ), counts AS ( SELECT list_id, JSON_OBJECT_AGG(status, subscriber_count) AS subscriber_statuses FROM ( @@ -447,7 +449,7 @@ SELECT c.id, c.uuid, c.name, c.subject, c.from_email, ) AS lists FROM campaigns c WHERE ($1 = 0 OR id = $1) - AND status=ANY(CASE WHEN ARRAY_LENGTH($2::campaign_status[], 1) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END) + AND status=ANY(CASE WHEN CARDINALITY($2::campaign_status[]) != 0 THEN $2::campaign_status[] ELSE ARRAY[status] END) AND ($3 = '' OR CONCAT(name, subject) ILIKE $3) ORDER BY %s %s OFFSET $4 LIMIT (CASE WHEN $5 = 0 THEN NULL ELSE $5 END); @@ -767,7 +769,7 @@ WITH u AS ( UPDATE templates SET is_default=false WHERE id != $1; -- name: delete-template --- Delete a template as long as there's more than one. One deletion, set all campaigns +-- Delete a template as long as there's more than one. On deletion, set all campaigns -- with that template to the default template instead. WITH tpl AS ( DELETE FROM templates WHERE id = $1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false RETURNING id @@ -781,10 +783,13 @@ UPDATE campaigns SET template_id = (SELECT id FROM def) WHERE (SELECT id FROM tp -- media -- name: insert-media -INSERT INTO media (uuid, filename, thumb, provider, created_at) VALUES($1, $2, $3, $4, NOW()); +INSERT INTO media (uuid, filename, thumb, provider, created_at) VALUES($1, $2, $3, $4, NOW()) RETURNING id; + +-- name: get-all-media +SELECT * FROM media WHERE provider=$1 ORDER BY created_at DESC; -- name: get-media -SELECT * FROM media WHERE provider=$1 ORDER BY created_at DESC; +SELECT * FROM media WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; -- name: delete-media DELETE FROM media WHERE id=$1 RETURNING filename; @@ -934,7 +939,7 @@ WHERE ($1 = 0 OR bounces.id = $1) ORDER BY %s %s OFFSET $5 LIMIT $6; -- name: delete-bounces -DELETE FROM bounces WHERE ARRAY_LENGTH($1::INT[], 1) IS NULL OR id = ANY($1); +DELETE FROM bounces WHERE CARDINALITY($1::INT[]) = 0 OR id = ANY($1); -- name: delete-bounces-by-subscriber WITH sub AS (