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:
Kailash Nadh 2022-04-03 20:54:40 +05:30
parent 12b845ef97
commit b5cd9498b1
30 changed files with 2130 additions and 1341 deletions

View file

@ -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})

View file

@ -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})

View file

@ -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)
}

View file

@ -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
}

View file

@ -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,

View file

@ -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})

View file

@ -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)

View file

@ -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,
}

View file

@ -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})
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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})

View file

@ -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
}

View file

@ -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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
}

View 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
}

View 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
}

View 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
}

View file

@ -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.

View file

@ -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

View file

@ -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
View 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"`
}

View file

@ -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 (