Refactore all CRUD functions to a new core
package.
This is a long pending refactor. All the DB, query, CRUD, and related logic scattered across HTTP handlers are now moved into a central `core` package with clean, abstracted methods, decoupling HTTP handlers from executing direct DB queries and other business logic. eg: `core.CreateList()`, `core.GetLists()` etc. - Remove obsolete subscriber methods. - Move optin hook queries to core. - Move campaign methods to `core`. - Move all campaign methods to `core`. - Move public page functions to `core`. - Move all template functions to `core`. - Move media and settings function to `core`. - Move handler middleware functions to `core`. - Move all bounce functions to `core`. - Move all dashboard functions to `core`. - Fix GetLists() not honouring type - Fix unwrapped JSON responses. - Clean up obsolete pre-core util function. - Replace SQL array null check with cardinality check. - Fix missing validations in `core` queries. - Remove superfluous deps on internal `subimporter`. - Add dashboard functions to `core`. - Fix broken domain ban check. - Fix broken subscriber check middleware. - Remove redundant error handling. - Remove obsolete functions. - Remove obsolete structs. - Remove obsolete queries and DB functions. - Document the `core` package.
This commit is contained in:
parent
12b845ef97
commit
b5cd9498b1
30 changed files with 2130 additions and 1341 deletions
15
cmd/admin.go
15
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})
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -77,21 +58,16 @@ func handleGetBounces(c echo.Context) error {
|
|||
func handleGetSubscriberBounces(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
subID = c.Param("id")
|
||||
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})
|
||||
|
|
401
cmd/campaigns.go
401
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"}
|
||||
)
|
||||
|
||||
// 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,57 +60,48 @@ 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)
|
||||
// Meta.
|
||||
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.Results[i].Body = ""
|
||||
out.Body = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load stats.
|
||||
if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
|
||||
app.log.Printf("error fetching campaign stats: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if single {
|
||||
return c.JSON(http.StatusOK, okResp{out.Results[0]})
|
||||
}
|
||||
|
||||
// Meta.
|
||||
out.Total = out.Results[0].Total
|
||||
out.Page = pg.Page
|
||||
out.PerPage = pg.PerPage
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
@ -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}"))
|
||||
var o struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
app.log.Printf("error fetching campaign: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Incoming params.
|
||||
var o campaignReq
|
||||
if err := c.Bind(&o); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
errMsg := ""
|
||||
switch o.Status {
|
||||
case models.CampaignStatusDraft:
|
||||
if cm.Status != models.CampaignStatusScheduled {
|
||||
errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
|
||||
}
|
||||
case models.CampaignStatusScheduled:
|
||||
if cm.Status != models.CampaignStatusDraft {
|
||||
errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
|
||||
}
|
||||
if !cm.SendAt.Valid {
|
||||
errMsg = app.i18n.T("campaigns.needsSendAt")
|
||||
}
|
||||
|
||||
case models.CampaignStatusRunning:
|
||||
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
|
||||
errMsg = app.i18n.T("campaigns.onlyPausedDraft")
|
||||
}
|
||||
case models.CampaignStatusPaused:
|
||||
if cm.Status != models.CampaignStatusRunning {
|
||||
errMsg = app.i18n.T("campaigns.onlyActivePause")
|
||||
}
|
||||
case models.CampaignStatusCancelled:
|
||||
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
|
||||
errMsg = app.i18n.T("campaigns.onlyActiveCancel")
|
||||
}
|
||||
}
|
||||
|
||||
if len(errMsg) > 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, errMsg)
|
||||
}
|
||||
|
||||
res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
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")))
|
||||
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)))
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return c.Render(http.StatusNotFound, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
|
||||
app.i18n.T("public.subNotFound")))
|
||||
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")))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
42
cmd/init.go
42
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,
|
||||
|
|
145
cmd/lists.go
145
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})
|
||||
|
|
19
cmd/main.go
19
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)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
49
cmd/media.go
49
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})
|
||||
}
|
||||
|
@ -119,18 +108,21 @@ func handleUploadMedia(c echo.Context) error {
|
|||
func handleGetMedia(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out = []media.Media{}
|
||||
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})
|
||||
}
|
||||
|
||||
|
|
152
cmd/public.go
152
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 {
|
||||
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")))
|
||||
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
|
||||
|
|
143
cmd/settings.go
143
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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
@ -27,30 +22,13 @@ const (
|
|||
// 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"`
|
||||
ListIDs []int `json:"list_ids"`
|
||||
TargetListIDs []int `json:"target_list_ids"`
|
||||
SubscriberIDs []int `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"`
|
||||
}
|
||||
|
||||
// subProfileData represents a subscriber's collated data in JSON
|
||||
// for export.
|
||||
type subProfileData struct {
|
||||
|
@ -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})
|
||||
// Get the batched export iterator.
|
||||
exp, err := app.core.ExportSubscribers(query, subIDs, listIDs, app.constants.DBBatchSize)
|
||||
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)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Prepare the actual query statement.
|
||||
tx, err := db.Preparex(stmt)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(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})
|
||||
|
@ -412,16 +292,17 @@ func handleBlocklistSubscribers(c echo.Context) error {
|
|||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pID = c.Param("id")
|
||||
IDs pq.Int64Array
|
||||
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})
|
||||
|
@ -452,16 +332,16 @@ func handleManageSubscriberLists(c echo.Context) error {
|
|||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pID = c.Param("id")
|
||||
IDs pq.Int64Array
|
||||
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})
|
||||
|
@ -508,16 +385,16 @@ func handleDeleteSubscribers(c echo.Context) error {
|
|||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pID = c.Param("id")
|
||||
IDs pq.Int64Array
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
err := app.queries.GetTemplates.Select(&out, id, noBody)
|
||||
out, err := app.core.GetTemplate(id, 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}"))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
|
||||
} else if single {
|
||||
return c.JSON(http.StatusOK, okResp{out[0]})
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
out, err := app.core.GetTemplates(noBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
|
@ -70,10 +60,9 @@ func handleGetTemplates(c echo.Context) error {
|
|||
func handlePreviewTemplate(c echo.Context) error {
|
||||
var (
|
||||
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})
|
||||
|
|
38
cmd/utils.go
38
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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
66
internal/core/bounces.go
Normal file
66
internal/core/bounces.go
Normal file
|
@ -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
|
||||
}
|
337
internal/core/campaigns.go
Normal file
337
internal/core/campaigns.go
Normal file
|
@ -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
|
||||
}
|
150
internal/core/core.go
Normal file
150
internal/core/core.go
Normal file
|
@ -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
|
||||
}
|
30
internal/core/dashboard.go
Normal file
30
internal/core/dashboard.go
Normal file
|
@ -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
|
||||
}
|
133
internal/core/lists.go
Normal file
133
internal/core/lists.go
Normal file
|
@ -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
|
||||
}
|
77
internal/core/media.go
Normal file
77
internal/core/media.go
Normal file
|
@ -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
|
||||
}
|
50
internal/core/settings.go
Normal file
50
internal/core/settings.go
Normal file
|
@ -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
|
||||
}
|
436
internal/core/subscribers.go
Normal file
436
internal/core/subscribers.go
Normal file
|
@ -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
|
||||
}
|
93
internal/core/subscriptions.go
Normal file
93
internal/core/subscriptions.go
Normal file
|
@ -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
|
||||
}
|
87
internal/core/templates.go
Normal file
87
internal/core/templates.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
102
models/settings.go
Normal file
102
models/settings.go
Normal file
|
@ -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"`
|
||||
}
|
33
queries.sql
33
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 (
|
||||
|
|
Loading…
Reference in a new issue