Browse Source

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.
Kailash Nadh 3 years ago
parent
commit
b5cd9498b1

+ 6 - 9
cmd/admin.go

@@ -7,7 +7,6 @@ import (
 	"syscall"
 	"syscall"
 	"time"
 	"time"
 
 
-	"github.com/jmoiron/sqlx/types"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4"
 )
 )
 
 
@@ -61,12 +60,11 @@ func handleGetServerConfig(c echo.Context) error {
 func handleGetDashboardCharts(c echo.Context) error {
 func handleGetDashboardCharts(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		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})
 	return c.JSON(http.StatusOK, okResp{out})
@@ -76,12 +74,11 @@ func handleGetDashboardCharts(c echo.Context) error {
 func handleGetDashboardCounts(c echo.Context) error {
 func handleGetDashboardCounts(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		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})
 	return c.JSON(http.StatusOK, okResp{out})

+ 24 - 51
cmd/bounce.go

@@ -2,7 +2,6 @@ package main
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
-	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
@@ -10,23 +9,13 @@ import (
 
 
 	"github.com/knadh/listmonk/models"
 	"github.com/knadh/listmonk/models"
 	"github.com/labstack/echo/v4"
 	"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.
 // handleGetBounces handles retrieval of bounce records.
 func handleGetBounces(c echo.Context) error {
 func handleGetBounces(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
 		pg  = getPagination(c.QueryParams(), 50)
 		pg  = getPagination(c.QueryParams(), 50)
-		out bouncesWrap
 
 
 		id, _     = strconv.Atoi(c.Param("id"))
 		id, _     = strconv.Atoi(c.Param("id"))
 		campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
 		campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
@@ -35,38 +24,30 @@ func handleGetBounces(c echo.Context) error {
 		order     = c.FormValue("order")
 		order     = c.FormValue("order")
 	)
 	)
 
 
-	// Fetch one list.
-	single := false
+	// Fetch one bounce.
 	if id > 0 {
 	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{}
 		out.Results = []models.Bounce{}
 		return c.JSON(http.StatusOK, okResp{out})
 		return c.JSON(http.StatusOK, okResp{out})
 	}
 	}
 
 
-	if single {
-		return c.JSON(http.StatusOK, okResp{out.Results[0]})
-	}
-
 	// Meta.
 	// Meta.
-	out.Total = out.Results[0].Total
+	out.Results = res
+	out.Total = res[0].Total
 	out.Page = pg.Page
 	out.Page = pg.Page
 	out.PerPage = pg.PerPage
 	out.PerPage = pg.PerPage
 
 
@@ -76,22 +57,17 @@ func handleGetBounces(c echo.Context) error {
 // handleGetSubscriberBounces retrieves a subscriber's bounce records.
 // handleGetSubscriberBounces retrieves a subscriber's bounce records.
 func handleGetSubscriberBounces(c echo.Context) error {
 func handleGetSubscriberBounces(c echo.Context) error {
 	var (
 	var (
-		app   = c.Get("app").(*App)
-		subID = c.Param("id")
+		app      = c.Get("app").(*App)
+		subID, _ = strconv.Atoi(c.Param("id"))
 	)
 	)
 
 
-	id, _ := strconv.ParseInt(subID, 10, 64)
-	if id < 1 {
+	if subID < 1 {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		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})
 	return c.JSON(http.StatusOK, okResp{out})
@@ -103,12 +79,12 @@ func handleDeleteBounces(c echo.Context) error {
 		app    = c.Get("app").(*App)
 		app    = c.Get("app").(*App)
 		pID    = c.Param("id")
 		pID    = c.Param("id")
 		all, _ = strconv.ParseBool(c.QueryParam("all"))
 		all, _ = strconv.ParseBool(c.QueryParam("all"))
-		IDs    = pq.Int64Array{}
+		IDs    = []int{}
 	)
 	)
 
 
 	// Is it an /:id call?
 	// Is it an /:id call?
 	if pID != "" {
 	if pID != "" {
-		id, _ := strconv.ParseInt(pID, 10, 64)
+		id, _ := strconv.Atoi(pID)
 		if id < 1 {
 		if id < 1 {
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		}
 		}
@@ -128,11 +104,8 @@ func handleDeleteBounces(c echo.Context) error {
 		IDs = i
 		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})
 	return c.JSON(http.StatusOK, okResp{true})

+ 80 - 323
cmd/campaigns.go

@@ -2,7 +2,6 @@ package main
 
 
 import (
 import (
 	"bytes"
 	"bytes"
-	"database/sql"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
@@ -13,12 +12,9 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
-	"github.com/gofrs/uuid"
-	"github.com/jmoiron/sqlx"
 	"github.com/knadh/listmonk/models"
 	"github.com/knadh/listmonk/models"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4"
 	"github.com/lib/pq"
 	"github.com/lib/pq"
-	null "gopkg.in/volatiletech/null.v6"
 )
 )
 
 
 // campaignReq is a wrapper over the Campaign model for receiving
 // campaignReq is a wrapper over the Campaign model for receiving
@@ -27,18 +23,16 @@ type campaignReq struct {
 	models.Campaign
 	models.Campaign
 
 
 	// Indicates if the "send_at" date should be written or set to null.
 	// 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
 	// This overrides Campaign.Lists to receive and
 	// write a list of int IDs during creation and updation.
 	// write a list of int IDs during creation and updation.
 	// Campaign.Lists is JSONText for sending lists children
 	// Campaign.Lists is JSONText for sending lists children
 	// to the outside world.
 	// to the outside world.
-	ListIDs pq.Int64Array `db:"-" json:"lists"`
+	ListIDs []int `json:"lists"`
 
 
 	// This is only relevant to campaign test requests.
 	// This is only relevant to campaign test requests.
 	SubscriberEmails pq.StringArray `json:"subscribers"`
 	SubscriberEmails pq.StringArray `json:"subscribers"`
-
-	Type string `json:"type"`
 }
 }
 
 
 // campaignContentReq wraps params coming from API requests for converting
 // campaignContentReq wraps params coming from API requests for converting
@@ -49,43 +43,8 @@ type campaignContentReq struct {
 	To   string `json:"to"`
 	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 (
 var (
-	regexFromAddress   = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
-	regexFullTextQuery = regexp.MustCompile(`\s+`)
-
-	campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
-	bounceQuerySortFields   = []string{"email", "campaign_name", "source", "created_at"}
+	regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
 )
 )
 
 
 // handleGetCampaigns handles retrieval of campaigns.
 // handleGetCampaigns handles retrieval of campaigns.
@@ -93,9 +52,7 @@ func handleGetCampaigns(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
 		pg  = getPagination(c.QueryParams(), 20)
 		pg  = getPagination(c.QueryParams(), 20)
-		out campsWrap
 
 
-		id, _     = strconv.Atoi(c.Param("id"))
 		status    = c.QueryParams()["status"]
 		status    = c.QueryParams()["status"]
 		query     = strings.TrimSpace(c.FormValue("query"))
 		query     = strings.TrimSpace(c.FormValue("query"))
 		orderBy   = c.FormValue("order_by")
 		orderBy   = c.FormValue("order_by")
@@ -103,57 +60,48 @@ func handleGetCampaigns(c echo.Context) error {
 		noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
 		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)
-
-	// 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 noBody {
+		for i := 0; i < len(res); i++ {
+			res[i].Body = ""
+		}
 	}
 	}
-	if len(out.Results) == 0 {
+
+	var out models.PageResults
+	if len(res) == 0 {
 		out.Results = []models.Campaign{}
 		out.Results = []models.Campaign{}
 		return c.JSON(http.StatusOK, okResp{out})
 		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
 
 
-		if noBody {
-			out.Results[i].Body = ""
-		}
-	}
+	return c.JSON(http.StatusOK, okResp{out})
+}
 
 
-	// 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)))
-	}
+// 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"))
+	)
 
 
-	if single {
-		return c.JSON(http.StatusOK, okResp{out.Results[0]})
+	out, err := app.core.GetCampaign(id, "")
+	if err != nil {
+		return err
 	}
 	}
 
 
-	// Meta.
-	out.Total = out.Results[0].Total
-	out.Page = pg.Page
-	out.PerPage = pg.PerPage
+	if noBody {
+		out.Body = ""
+	}
 
 
 	return c.JSON(http.StatusOK, okResp{out})
 	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"))
 		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.
 	// 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
 		o = c
 	}
 	}
 
 
-	uu, err := uuid.NewV4()
+	out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs)
 	if err != nil {
 	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()))
-	}
-
-	// 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)))
+		return 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.
 // 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) {
 	if isCampaignalMutable(cm.Status) {
@@ -359,28 +258,12 @@ func handleUpdateCampaign(c echo.Context) error {
 		o = c
 		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 {
 	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.
 // 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"))
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 	}
 	}
 
 
-	var cm models.Campaign
-	if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
-		if err == sql.ErrNoRows {
-			return echo.NewHTTPError(http.StatusBadRequest,
-				app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}"))
-		}
-
-		app.log.Printf("error fetching campaign: %v", err)
-		return echo.NewHTTPError(http.StatusInternalServerError,
-			app.i18n.Ts("globals.messages.errorFetching",
-				"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	var o struct {
+		Status string `json:"status"`
 	}
 	}
 
 
-	// Incoming params.
-	var o campaignReq
 	if err := c.Bind(&o); err != nil {
 	if err := c.Bind(&o); err != nil {
 		return err
 		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 {
 	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)))
-	}
-
-	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 err
 	}
 	}
 
 
-	return handleGetCampaigns(c)
+	return c.JSON(http.StatusOK, okResp{out})
 }
 }
 
 
 // handleDeleteCampaign handles campaign deletion.
 // 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"))
 		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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -504,19 +316,14 @@ func handleDeleteCampaign(c echo.Context) error {
 func handleGetRunningCampaignStats(c echo.Context) error {
 func handleGetRunningCampaignStats(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		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{}{}})
 		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++ {
 	for i := 0; i < len(req.SubscriberEmails); i++ {
 		req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(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.
 	// 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.
 	// 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`"))
 			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) {
 	if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
 		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})
 	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.
 	// 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 {
 	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.
 	// No opt-in lists.
@@ -802,22 +578,3 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
 	o.Body = b.String()
 	o.Body = b.String()
 	return o, nil
 	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)
-}

+ 10 - 30
cmd/handlers.go

@@ -86,6 +86,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
 	g.DELETE("/api/subscribers", handleDeleteSubscribers)
 	g.DELETE("/api/subscribers", handleDeleteSubscribers)
 
 
 	g.GET("/api/bounces", handleGetBounces)
 	g.GET("/api/bounces", handleGetBounces)
+	g.GET("/api/bounces/:id", handleGetBounces)
 	g.DELETE("/api/bounces", handleDeleteBounces)
 	g.DELETE("/api/bounces", handleDeleteBounces)
 	g.DELETE("/api/bounces/:id", 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", handleGetCampaigns)
 	g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
 	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/analytics/:type", handleGetCampaignViewAnalytics)
 	g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
 	g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
 	g.POST("/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.DELETE("/api/campaigns/:id", handleDeleteCampaign)
 
 
 	g.GET("/api/media", handleGetMedia)
 	g.GET("/api/media", handleGetMedia)
+	g.GET("/api/media/:id", handleGetMedia)
 	g.POST("/api/media", handleUploadMedia)
 	g.POST("/api/media", handleUploadMedia)
 	g.DELETE("/api/media/:id", handleDeleteMedia)
 	g.DELETE("/api/media/:id", handleDeleteMedia)
 
 
@@ -264,19 +266,17 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc
 			subUUID = c.Param("subUUID")
 			subUUID = c.Param("subUUID")
 		)
 		)
 
 
-		var exists bool
-		if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
+		if _, err := app.core.GetSubscriber(0, subUUID, ""); err != nil {
+			if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
+				return c.Render(http.StatusNotFound, tplMessage,
+					makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", er.Message.(string)))
+			}
+
 			app.log.Printf("error checking subscriber existence: %v", err)
 			app.log.Printf("error checking subscriber existence: %v", err)
 			return c.Render(http.StatusInternalServerError, tplMessage,
 			return c.Render(http.StatusInternalServerError, tplMessage,
-				makeMsgTpl(app.i18n.T("public.errorTitle"), "",
-					app.i18n.T("public.errorProcessingRequest")))
+				makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
 		}
 		}
 
 
-		if !exists {
-			return c.Render(http.StatusNotFound, tplMessage,
-				makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
-					app.i18n.T("public.subNotFound")))
-		}
 		return next(c)
 		return next(c)
 	}
 	}
 }
 }
@@ -319,23 +319,3 @@ func getPagination(q url.Values, perPage int) pagination {
 		Limit:   perPage,
 		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
-}

+ 33 - 9
cmd/init.go

@@ -36,6 +36,7 @@ import (
 	"github.com/knadh/listmonk/models"
 	"github.com/knadh/listmonk/models"
 	"github.com/knadh/stuffbin"
 	"github.com/knadh/stuffbin"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4"
+	"github.com/lib/pq"
 	flag "github.com/spf13/pflag"
 	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
 // initDB initializes the main DB connection pool and parse and loads the app's
 // SQL queries into a prepared query map.
 // SQL queries into a prepared query map.
 func initDB() *sqlx.DB {
 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.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 {
 	if err != nil {
 		lo.Fatalf("error connecting to DB: %v", err)
 		lo.Fatalf("error connecting to DB: %v", err)
 	}
 	}
+
+	db.SetMaxOpenConns(c.MaxOpen)
+	db.SetMaxIdleConns(c.MaxIdle)
+	db.SetConnMaxLifetime(c.MaxLifetime)
+
 	return db
 	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
 // 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
 	// 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
 	// 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
 	// 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.
 	// Scan and prepare all queries.
-	var q Queries
+	var q models.Queries
 	if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
 	if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
 		lo.Fatalf("error preparing SQL queries: %v", err)
 		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) {
 func initSettings(query string, db *sqlx.DB, ko *koanf.Koanf) {
 	var s types.JSONText
 	var s types.JSONText
 	if err := db.Get(&s, query); err != nil {
 	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
 	// 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.
 // 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 {
 	campNotifCB := func(subject string, data interface{}) error {
 		return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data)
 		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.
 // 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(
 	return subimporter.New(
 		subimporter.Options{
 		subimporter.Options{
 			DomainBlocklist:    app.constants.Privacy.DomainBlocklist,
 			DomainBlocklist:    app.constants.Privacy.DomainBlocklist,

+ 42 - 101
cmd/lists.go

@@ -1,35 +1,19 @@
 package main
 package main
 
 
 import (
 import (
-	"fmt"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 
 
-	"github.com/gofrs/uuid"
 	"github.com/knadh/listmonk/models"
 	"github.com/knadh/listmonk/models"
-	"github.com/lib/pq"
-
 	"github.com/labstack/echo/v4"
 	"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.
 // handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
 func handleGetLists(c echo.Context) error {
 func handleGetLists(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
-		out listsWrap
+		out models.PageResults
 
 
 		pg         = getPagination(c.QueryParams(), 20)
 		pg         = getPagination(c.QueryParams(), 20)
 		query      = strings.TrimSpace(c.FormValue("query"))
 		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.
 	// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
 	if !single && minimal {
 	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{}{}})
 			return c.JSON(http.StatusOK, okResp{[]struct{}{}})
 		}
 		}
 
 
 		// Meta.
 		// Meta.
-		out.Total = out.Results[0].Total
+		out.Results = res
+		out.Total = len(res)
 		out.Page = 1
 		out.Page = 1
 		out.PerPage = out.Total
 		out.PerPage = out.Total
-		if out.PerPage == 0 {
-			out.PerPage = out.Total
-		}
+
 		return c.JSON(http.StatusOK, okResp{out})
 		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,
 		return echo.NewHTTPError(http.StatusBadRequest,
 			app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
 			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.
 	// Replace null tags.
-	for i, v := range out.Results {
+	for i, v := range res {
 		if v.Tags == nil {
 		if v.Tags == nil {
-			out.Results[i].Tags = make(pq.StringArray, 0)
+			res[i].Tags = make([]string, 0)
 		}
 		}
 
 
 		// Total counts.
 		// Total counts.
 		for _, c := range v.SubscriberCounts {
 		for _, c := range v.SubscriberCounts {
-			out.Results[i].SubscriberCount += c
+			res[i].SubscriberCount += c
 		}
 		}
 	}
 	}
 
 
 	if single {
 	if single {
-		return c.JSON(http.StatusOK, okResp{out.Results[0]})
+		return c.JSON(http.StatusOK, okResp{res[0]})
 	}
 	}
 
 
 	// Meta.
 	// 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.Page = pg.Page
 	out.PerPage = pg.PerPage
 	out.PerPage = pg.PerPage
 	if out.PerPage == 0 {
 	if out.PerPage == 0 {
@@ -119,51 +94,24 @@ func handleGetLists(c echo.Context) error {
 func handleCreateList(c echo.Context) error {
 func handleCreateList(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		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
 		return err
 	}
 	}
 
 
 	// Validate.
 	// Validate.
-	if !strHasLen(o.Name, 1, stdInputMaxLen) {
+	if !strHasLen(l.Name, 1, stdInputMaxLen) {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
 	}
 	}
 
 
-	uu, err := uuid.NewV4()
+	out, err := app.core.CreateList(l)
 	if err != nil {
 	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()))
-	}
-
-	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)))
+		return 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.
 // handleUpdateList handles list modification.
@@ -178,26 +126,22 @@ func handleUpdateList(c echo.Context) error {
 	}
 	}
 
 
 	// Incoming params.
 	// 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
 		return err
 	}
 	}
 
 
-	res, err := app.queries.UpdateList.Exec(id,
-		o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags)))
-	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)))
+	// Validate.
+	if !strHasLen(l.Name, 1, stdInputMaxLen) {
+		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
 	}
 	}
 
 
-	if n, _ := res.RowsAffected(); n == 0 {
-		return echo.NewHTTPError(http.StatusBadRequest,
-			app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
+	out, err := app.core.UpdateList(id, l)
+	if err != nil {
+		return err
 	}
 	}
 
 
-	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.
 // 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 (
 	var (
 		app   = c.Get("app").(*App)
 		app   = c.Get("app").(*App)
 		id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
 		id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
-		ids   pq.Int64Array
+		ids   []int
 	)
 	)
 
 
 	if id < 1 && len(ids) == 0 {
 	if id < 1 && len(ids) == 0 {
@@ -213,14 +157,11 @@ func handleDeleteLists(c echo.Context) error {
 	}
 	}
 
 
 	if id > 0 {
 	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})
 	return c.JSON(http.StatusOK, okResp{true})

+ 17 - 2
cmd/main.go

@@ -17,11 +17,13 @@ import (
 	"github.com/knadh/koanf/providers/env"
 	"github.com/knadh/koanf/providers/env"
 	"github.com/knadh/listmonk/internal/bounce"
 	"github.com/knadh/listmonk/internal/bounce"
 	"github.com/knadh/listmonk/internal/buflog"
 	"github.com/knadh/listmonk/internal/buflog"
+	"github.com/knadh/listmonk/internal/core"
 	"github.com/knadh/listmonk/internal/i18n"
 	"github.com/knadh/listmonk/internal/i18n"
 	"github.com/knadh/listmonk/internal/manager"
 	"github.com/knadh/listmonk/internal/manager"
 	"github.com/knadh/listmonk/internal/media"
 	"github.com/knadh/listmonk/internal/media"
 	"github.com/knadh/listmonk/internal/messenger"
 	"github.com/knadh/listmonk/internal/messenger"
 	"github.com/knadh/listmonk/internal/subimporter"
 	"github.com/knadh/listmonk/internal/subimporter"
+	"github.com/knadh/listmonk/models"
 	"github.com/knadh/stuffbin"
 	"github.com/knadh/stuffbin"
 )
 )
 
 
@@ -32,9 +34,10 @@ const (
 // App contains the "global" components that are
 // App contains the "global" components that are
 // passed around, especially through HTTP handlers.
 // passed around, especially through HTTP handlers.
 type App struct {
 type App struct {
+	core       *core.Core
 	fs         stuffbin.FileSystem
 	fs         stuffbin.FileSystem
 	db         *sqlx.DB
 	db         *sqlx.DB
-	queries    *Queries
+	queries    *models.Queries
 	constants  *constants
 	constants  *constants
 	manager    *manager.Manager
 	manager    *manager.Manager
 	importer   *subimporter.Importer
 	importer   *subimporter.Importer
@@ -67,7 +70,7 @@ var (
 	ko      = koanf.New(".")
 	ko      = koanf.New(".")
 	fs      stuffbin.FileSystem
 	fs      stuffbin.FileSystem
 	db      *sqlx.DB
 	db      *sqlx.DB
-	queries *Queries
+	queries *models.Queries
 
 
 	// Compile-time variables.
 	// Compile-time variables.
 	buildString   string
 	buildString   string
@@ -168,6 +171,18 @@ func main() {
 	// Load i18n language map.
 	// Load i18n language map.
 	app.i18n = initI18n(app.constants.Lang, fs)
 	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.queries = queries
 	app.manager = initCampaignManager(app.queries, app.constants, app)
 	app.manager = initCampaignManager(app.queries, app.constants, app)
 	app.importer = initImporter(app.queries, db, app)
 	app.importer = initImporter(app.queries, db, app)

+ 2 - 2
cmd/manager_store.go

@@ -9,10 +9,10 @@ import (
 // runnerDB implements runner.DataSource over the primary
 // runnerDB implements runner.DataSource over the primary
 // database.
 // database.
 type runnerDB struct {
 type runnerDB struct {
-	queries *Queries
+	queries *models.Queries
 }
 }
 
 
-func newManagerStore(q *Queries) *runnerDB {
+func newManagerStore(q *models.Queries) *runnerDB {
 	return &runnerDB{
 	return &runnerDB{
 		queries: q,
 		queries: q,
 	}
 	}

+ 21 - 30
cmd/media.go

@@ -8,8 +8,6 @@ import (
 	"strconv"
 	"strconv"
 
 
 	"github.com/disintegration/imaging"
 	"github.com/disintegration/imaging"
-	"github.com/gofrs/uuid"
-	"github.com/knadh/listmonk/internal/media"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4"
 )
 )
 
 
@@ -97,20 +95,11 @@ func handleUploadMedia(c echo.Context) error {
 			app.i18n.Ts("media.errorSavingThumbnail", "error", err.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.
 	// 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
 		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})
 	return c.JSON(http.StatusOK, okResp{true})
 }
 }
@@ -118,19 +107,22 @@ func handleUploadMedia(c echo.Context) error {
 // handleGetMedia handles retrieval of uploaded media.
 // handleGetMedia handles retrieval of uploaded media.
 func handleGetMedia(c echo.Context) error {
 func handleGetMedia(c echo.Context) error {
 	var (
 	var (
-		app = c.Get("app").(*App)
-		out = []media.Media{}
+		app   = c.Get("app").(*App)
+		id, _ = strconv.Atoi(c.Param("id"))
 	)
 	)
 
 
-	if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil {
-		return echo.NewHTTPError(http.StatusInternalServerError,
-			app.i18n.Ts("globals.messages.errorFetching",
-				"name", "{globals.terms.media}", "error", pqErrMsg(err)))
+	// Fetch one list.
+	if id > 0 {
+		out, err := app.core.GetMedia(id, "", app.media)
+		if err != nil {
+			return err
+		}
+		return c.JSON(http.StatusOK, okResp{out})
 	}
 	}
 
 
-	for i := 0; i < len(out); i++ {
-		out[i].URL = app.media.Get(out[i].Filename)
-		out[i].ThumbURL = app.media.Get(out[i].Thumb)
+	out, err := app.core.GetAllMedia(app.constants.MediaProvider, app.media)
+	if err != nil {
+		return err
 	}
 	}
 
 
 	return c.JSON(http.StatusOK, okResp{out})
 	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"))
 		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})
 	return c.JSON(http.StatusOK, okResp{true})
 }
 }
 
 

+ 64 - 90
cmd/public.go

@@ -73,11 +73,6 @@ type subFormTpl struct {
 	Lists []models.List
 	Lists []models.List
 }
 }
 
 
-type subForm struct {
-	subimporter.SubReq
-	SubListUUIDs []string `form:"l"`
-}
-
 var (
 var (
 	pixelPNG = drawTransparentImage(3, 14)
 	pixelPNG = drawTransparentImage(3, 14)
 )
 )
@@ -103,40 +98,37 @@ func handleViewCampaignMessage(c echo.Context) error {
 	)
 	)
 
 
 	// Get the campaign.
 	// Get the campaign.
-	var camp models.Campaign
-	if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
-		if err == sql.ErrNoRows {
-			return c.Render(http.StatusNotFound, tplMessage,
-				makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
-					app.i18n.T("public.campaignNotFound")))
+	camp, err := app.core.GetCampaign(0, campUUID)
+	if err != nil {
+		if er, ok := err.(*echo.HTTPError); ok {
+			if er.Code == http.StatusBadRequest {
+				return c.Render(http.StatusNotFound, tplMessage,
+					makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
+			}
 		}
 		}
 
 
 		app.log.Printf("error fetching campaign: %v", err)
 		app.log.Printf("error fetching campaign: %v", err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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.
 	// Get the subscriber.
-	sub, err := getSubscriber(0, subUUID, "", app)
+	sub, err := app.core.GetSubscriber(0, subUUID, "")
 	if err != nil {
 	if err != nil {
 		if err == sql.ErrNoRows {
 		if err == sql.ErrNoRows {
 			return c.Render(http.StatusNotFound, tplMessage,
 			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,
 		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.
 	// Compile the template.
 	if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
 	if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
 		app.log.Printf("error compiling template: %v", err)
 		app.log.Printf("error compiling template: %v", err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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.
 	// Render the message body.
@@ -144,8 +136,7 @@ func handleViewCampaignMessage(c echo.Context) error {
 	if err != nil {
 	if err != nil {
 		app.log.Printf("error rendering message: %v", err)
 		app.log.Printf("error rendering message: %v", err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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()))
 	return c.HTML(http.StatusOK, string(msg.Body()))
@@ -176,16 +167,13 @@ func handleSubscriptionPage(c echo.Context) error {
 			blocklist = false
 			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,
 			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,
 		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)
 	return c.Render(http.StatusOK, "subscription", out)
@@ -215,40 +203,34 @@ func handleOptinPage(c echo.Context) error {
 		for _, l := range out.ListUUIDs {
 		for _, l := range out.ListUUIDs {
 			if !reUUID.MatchString(l) {
 			if !reUUID.MatchString(l) {
 				return c.Render(http.StatusBadRequest, tplMessage,
 				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.
 	// 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,
 		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.
 	// There are no lists to confirm.
-	if len(out.Lists) == 0 {
+	if len(lists) == 0 {
 		return c.Render(http.StatusOK, tplMessage,
 		return c.Render(http.StatusOK, tplMessage,
 			makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo")))
 			makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo")))
 	}
 	}
 
 
 	// Confirm.
 	// Confirm.
 	if 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)
 			app.log.Printf("error unsubscribing: %v", err)
 			return c.Render(http.StatusInternalServerError, tplMessage,
 			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,
 		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)
 	return c.Render(http.StatusOK, "optin", out)
@@ -263,23 +245,19 @@ func handleSubscriptionFormPage(c echo.Context) error {
 
 
 	if !app.constants.EnablePublicSubPage {
 	if !app.constants.EnablePublicSubPage {
 		return c.Render(http.StatusNotFound, tplMessage,
 		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.
 	// 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,
 		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 {
 	if len(lists) == 0 {
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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{}
 	out := subFormTpl{}
@@ -294,7 +272,10 @@ func handleSubscriptionFormPage(c echo.Context) error {
 func handleSubscriptionForm(c echo.Context) error {
 func handleSubscriptionForm(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
-		req subForm
+		req struct {
+			subimporter.SubReq
+			SubListUUIDs []string `form:"l"`
+		}
 	)
 	)
 
 
 	// Get and validate fields.
 	// 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 there's a nonce value, a bot could've filled the form.
 	if c.FormValue("nonce") != "" {
 	if c.FormValue("nonce") != "" {
 		return c.Render(http.StatusOK, tplMessage,
 		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 {
 	if len(req.SubListUUIDs) == 0 {
 		return c.Render(http.StatusBadRequest, tplMessage,
 		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.
 	// 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.
 	// 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()))
 			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.
 	// Insert the subscriber into the DB.
 	req.Status = models.SubscriberStatusEnabled
 	req.Status = models.SubscriberStatusEnabled
 	req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
 	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 {
 	if err != nil {
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		return c.Render(http.StatusInternalServerError, tplMessage,
 			makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
 			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
 	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)
 	return c.Redirect(http.StatusTemporaryRedirect, url)
@@ -398,7 +380,7 @@ func handleRegisterCampaignView(c echo.Context) error {
 
 
 	// Exclude dummy hits from template previews.
 	// Exclude dummy hits from template previews.
 	if campUUID != dummyUUID && subUUID != dummyUUID {
 	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)
 			app.log.Printf("error registering campaign view: %s", err)
 		}
 		}
 	}
 	}
@@ -419,8 +401,7 @@ func handleSelfExportSubscriberData(c echo.Context) error {
 	// Is export allowed?
 	// Is export allowed?
 	if !app.constants.Privacy.AllowExport {
 	if !app.constants.Privacy.AllowExport {
 		return c.Render(http.StatusBadRequest, tplMessage,
 		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,
 	// 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 {
 	if err != nil {
 		app.log.Printf("error exporting subscriber data: %s", err)
 		app.log.Printf("error exporting subscriber data: %s", err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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.
 	// 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 {
 	if err := app.notifTpls.tpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
 		app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
 		app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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.
 	// Send the data as a JSON attachment to the subscriber.
@@ -461,13 +440,11 @@ func handleSelfExportSubscriberData(c echo.Context) error {
 	}); err != nil {
 	}); err != nil {
 		app.log.Printf("error e-mailing subscriber profile: %s", err)
 		app.log.Printf("error e-mailing subscriber profile: %s", err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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,
 	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
 // handleWipeSubscriberData allows a subscriber to delete their data. The
@@ -482,20 +459,17 @@ func handleWipeSubscriberData(c echo.Context) error {
 	// Is wiping allowed?
 	// Is wiping allowed?
 	if !app.constants.Privacy.AllowWipe {
 	if !app.constants.Privacy.AllowWipe {
 		return c.Render(http.StatusBadRequest, tplMessage,
 		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)
 		app.log.Printf("error wiping subscriber data: %s", err)
 		return c.Render(http.StatusInternalServerError, tplMessage,
 		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,
 	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
 // drawTransparentImage draws a transparent PNG of given dimensions

+ 6 - 137
cmd/settings.go

@@ -1,7 +1,6 @@
 package main
 package main
 
 
 import (
 import (
-	"encoding/json"
 	"net/http"
 	"net/http"
 	"regexp"
 	"regexp"
 	"strings"
 	"strings"
@@ -9,110 +8,10 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/gofrs/uuid"
 	"github.com/gofrs/uuid"
-	"github.com/jmoiron/sqlx/types"
+	"github.com/knadh/listmonk/models"
 	"github.com/labstack/echo/v4"
 	"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 (
 var (
 	reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`)
 	reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`)
 )
 )
@@ -121,7 +20,7 @@ var (
 func handleGetSettings(c echo.Context) error {
 func handleGetSettings(c echo.Context) error {
 	app := c.Get("app").(*App)
 	app := c.Get("app").(*App)
 
 
-	s, err := getSettings(app)
+	s, err := app.core.GetSettings()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -146,7 +45,7 @@ func handleGetSettings(c echo.Context) error {
 func handleUpdateSettings(c echo.Context) error {
 func handleUpdateSettings(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
-		set settings
+		set models.Settings
 	)
 	)
 
 
 	// Unmarshal and marshal the fields once to sanitize the settings blob.
 	// 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.
 	// Get the existing settings.
-	cur, err := getSettings(app)
+	cur, err := app.core.GetSettings()
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -263,18 +162,9 @@ func handleUpdateSettings(c echo.Context) error {
 	}
 	}
 	set.DomainBlocklist = doms
 	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.
 	// 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
 	// 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)
 	app := c.Get("app").(*App)
 	return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()})
 	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
-}

+ 143 - 381
cmd/subscribers.go

@@ -1,8 +1,6 @@
 package main
 package main
 
 
 import (
 import (
-	"context"
-	"database/sql"
 	"encoding/csv"
 	"encoding/csv"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
@@ -12,11 +10,8 @@ import (
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 
 
-	"github.com/gofrs/uuid"
-	"github.com/knadh/listmonk/internal/subimporter"
 	"github.com/knadh/listmonk/models"
 	"github.com/knadh/listmonk/models"
 	"github.com/labstack/echo/v4"
 	"github.com/labstack/echo/v4"
-	"github.com/lib/pq"
 )
 )
 
 
 const (
 const (
@@ -26,29 +21,12 @@ const (
 // subQueryReq is a "catch all" struct for reading various
 // subQueryReq is a "catch all" struct for reading various
 // subscriber related requests.
 // subscriber related requests.
 type subQueryReq struct {
 type subQueryReq struct {
-	Query         string        `json:"query"`
-	ListIDs       pq.Int64Array `json:"list_ids"`
-	TargetListIDs pq.Int64Array `json:"target_list_ids"`
-	SubscriberIDs pq.Int64Array `json:"ids"`
-	Action        string        `json:"action"`
-	Status        string        `json:"status"`
-}
-
-type subsWrap struct {
-	Results models.Subscribers `json:"results"`
-
-	Query   string `json:"query"`
-	Total   int    `json:"total"`
-	PerPage int    `json:"per_page"`
-	Page    int    `json:"page"`
-}
-
-type subUpdateReq struct {
-	models.Subscriber
-	RawAttribs     json.RawMessage `json:"attribs"`
-	Lists          pq.Int64Array   `json:"lists"`
-	ListUUIDs      pq.StringArray  `json:"list_uuids"`
-	PreconfirmSubs bool            `json:"preconfirm_subscriptions"`
+	Query         string `json:"query"`
+	ListIDs       []int  `json:"list_ids"`
+	TargetListIDs []int  `json:"target_list_ids"`
+	SubscriberIDs []int  `json:"ids"`
+	Action        string `json:"action"`
+	Status        string `json:"status"`
 }
 }
 
 
 // subProfileData represents a subscriber's collated data in JSON
 // subProfileData represents a subscriber's collated data in JSON
@@ -63,7 +41,7 @@ type subProfileData struct {
 
 
 // subOptin contains the data that's passed to the double opt-in e-mail template.
 // subOptin contains the data that's passed to the double opt-in e-mail template.
 type subOptin struct {
 type subOptin struct {
-	*models.Subscriber
+	models.Subscriber
 
 
 	OptinURL string
 	OptinURL string
 	UnsubURL string
 	UnsubURL string
@@ -94,12 +72,12 @@ func handleGetSubscriber(c echo.Context) error {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		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 {
 	if err != nil {
 		return err
 		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.
 // 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"))
 		query   = sanitizeSQLExp(c.FormValue("query"))
 		orderBy = c.FormValue("order_by")
 		orderBy = c.FormValue("order_by")
 		order   = c.FormValue("order")
 		order   = c.FormValue("order")
-		out     = subsWrap{Results: make([]models.Subscriber, 0, 1)}
+		out     models.PageResults
 	)
 	)
 
 
 	// Limit the subscribers to specific lists?
 	// 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"))
 		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 {
 	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
 	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.Total = total
 	out.Page = pg.Page
 	out.Page = pg.Page
 	out.PerPage = pg.PerPage
 	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"))
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 	}
 	}
 
 
-	// There's an arbitrary query condition.
-	cond := ""
-	if query != "" {
-		cond = " AND " + query
-	}
-
-	stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond)
-
-	// Verify that the arbitrary SQL search expression is read only.
-	if cond != "" {
-		tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
-		if err != nil {
-			app.log.Printf("error preparing subscriber query: %v", err)
-			return echo.NewHTTPError(http.StatusBadRequest,
-				app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
-		}
-		defer tx.Rollback()
-
-		if _, err := tx.Query(stmt, nil, 0, nil, 1); err != nil {
-			return echo.NewHTTPError(http.StatusBadRequest,
-				app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
-		}
-	}
-
-	// Prepare the actual query statement.
-	tx, err := db.Preparex(stmt)
+	// Get the batched export iterator.
+	exp, err := app.core.ExportSubscribers(query, subIDs, listIDs, app.constants.DBBatchSize)
 	if err != nil {
 	if err != nil {
-		return echo.NewHTTPError(http.StatusBadRequest,
-			app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
+		return err
 	}
 	}
 
 
-	// Run the query until all rows are exhausted.
 	var (
 	var (
-		id = 0
-
 		h  = c.Response().Header()
 		h  = c.Response().Header()
 		wr = csv.NewWriter(c.Response())
 		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"})
 	wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"})
 
 
 loop:
 loop:
+	// Iterate in batches until there are no more subscribers to export.
 	for {
 	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 {
 		for _, r := range out {
@@ -275,9 +170,9 @@ loop:
 				break loop
 				break loop
 			}
 			}
 		}
 		}
-		wr.Flush()
 
 
-		id = out[len(out)-1].ID
+		// Flush CSV to stream after each batch.
+		wr.Flush()
 	}
 	}
 
 
 	return nil
 	return nil
@@ -287,7 +182,12 @@ loop:
 func handleCreateSubscriber(c echo.Context) error {
 func handleCreateSubscriber(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		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.
 	// Get and validate fields.
@@ -295,15 +195,24 @@ func handleCreateSubscriber(c echo.Context) error {
 		return err
 		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 {
 	if err != nil {
 		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
 		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.
 	// 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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -318,9 +227,14 @@ func handleCreateSubscriber(c echo.Context) error {
 func handleUpdateSubscriber(c echo.Context) error {
 func handleUpdateSubscriber(c echo.Context) error {
 	var (
 	var (
 		app   = c.Get("app").(*App)
 		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.
 	// Get and validate fields.
 	if err := c.Bind(&req); err != nil {
 	if err := c.Bind(&req); err != nil {
 		return err
 		return err
@@ -340,42 +254,12 @@ func handleUpdateSubscriber(c echo.Context) error {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
 		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 {
 	if err != nil {
 		return err
 		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.
 // handleGetSubscriberSendOptin sends an optin confirmation e-mail to a subscriber.
@@ -390,17 +274,13 @@ func handleSubscriberSendOptin(c echo.Context) error {
 	}
 	}
 
 
 	// Fetch the subscriber.
 	// Fetch the subscriber.
-	out, err := getSubscriber(id, "", "", app)
+	out, err := app.core.GetSubscriber(id, "", "")
 	if err != nil {
 	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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -410,18 +290,19 @@ func handleSubscriberSendOptin(c echo.Context) error {
 // It takes either an ID in the URI, or a list of IDs in the request body.
 // It takes either an ID in the URI, or a list of IDs in the request body.
 func handleBlocklistSubscribers(c echo.Context) error {
 func handleBlocklistSubscribers(c echo.Context) error {
 	var (
 	var (
-		app = c.Get("app").(*App)
-		pID = c.Param("id")
-		IDs pq.Int64Array
+		app    = c.Get("app").(*App)
+		pID    = c.Param("id")
+		subIDs []int
 	)
 	)
 
 
 	// Is it a /:id call?
 	// Is it a /:id call?
 	if pID != "" {
 	if pID != "" {
-		id, _ := strconv.ParseInt(pID, 10, 64)
+		id, _ := strconv.Atoi(pID)
 		if id < 1 {
 		if id < 1 {
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		}
 		}
-		IDs = append(IDs, id)
+
+		subIDs = append(subIDs, id)
 	} else {
 	} else {
 		// Multiple IDs.
 		// Multiple IDs.
 		var req subQueryReq
 		var req subQueryReq
@@ -433,13 +314,12 @@ func handleBlocklistSubscribers(c echo.Context) error {
 			return echo.NewHTTPError(http.StatusBadRequest,
 			return echo.NewHTTPError(http.StatusBadRequest,
 				"No IDs given.")
 				"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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -450,18 +330,18 @@ func handleBlocklistSubscribers(c echo.Context) error {
 // It takes either an ID in the URI, or a list of IDs in the request body.
 // It takes either an ID in the URI, or a list of IDs in the request body.
 func handleManageSubscriberLists(c echo.Context) error {
 func handleManageSubscriberLists(c echo.Context) error {
 	var (
 	var (
-		app = c.Get("app").(*App)
-		pID = c.Param("id")
-		IDs pq.Int64Array
+		app    = c.Get("app").(*App)
+		pID    = c.Param("id")
+		subIDs []int
 	)
 	)
 
 
 	// Is it an /:id call?
 	// Is it an /:id call?
 	if pID != "" {
 	if pID != "" {
-		id, _ := strconv.ParseInt(pID, 10, 64)
+		id, _ := strconv.Atoi(pID)
 		if id < 1 {
 		if id < 1 {
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		}
 		}
-		IDs = append(IDs, id)
+		subIDs = append(subIDs, id)
 	}
 	}
 
 
 	var req subQueryReq
 	var req subQueryReq
@@ -472,8 +352,8 @@ func handleManageSubscriberLists(c echo.Context) error {
 	if len(req.SubscriberIDs) == 0 {
 	if len(req.SubscriberIDs) == 0 {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
 		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 {
 	if len(req.TargetListIDs) == 0 {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
@@ -483,20 +363,17 @@ func handleManageSubscriberLists(c echo.Context) error {
 	var err error
 	var err error
 	switch req.Action {
 	switch req.Action {
 	case "add":
 	case "add":
-		_, err = app.queries.AddSubscribersToLists.Exec(IDs, req.TargetListIDs, req.Status)
+		err = app.core.AddSubscriptions(subIDs, req.TargetListIDs, req.Status)
 	case "remove":
 	case "remove":
-		_, err = app.queries.DeleteSubscriptions.Exec(IDs, req.TargetListIDs)
+		err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs)
 	case "unsubscribe":
 	case "unsubscribe":
-		_, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs)
+		err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs)
 	default:
 	default:
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
 	}
 	}
 
 
 	if err != nil {
 	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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -506,18 +383,18 @@ func handleManageSubscriberLists(c echo.Context) error {
 // It takes either an ID in the URI, or a list of IDs in the request body.
 // It takes either an ID in the URI, or a list of IDs in the request body.
 func handleDeleteSubscribers(c echo.Context) error {
 func handleDeleteSubscribers(c echo.Context) error {
 	var (
 	var (
-		app = c.Get("app").(*App)
-		pID = c.Param("id")
-		IDs pq.Int64Array
+		app    = c.Get("app").(*App)
+		pID    = c.Param("id")
+		subIDs []int
 	)
 	)
 
 
 	// Is it an /:id call?
 	// Is it an /:id call?
 	if pID != "" {
 	if pID != "" {
-		id, _ := strconv.ParseInt(pID, 10, 64)
+		id, _ := strconv.Atoi(pID)
 		if id < 1 {
 		if id < 1 {
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		}
 		}
-		IDs = append(IDs, id)
+		subIDs = append(subIDs, id)
 	} else {
 	} else {
 		// Multiple IDs.
 		// Multiple IDs.
 		i, err := parseStringIDs(c.Request().URL.Query()["id"])
 		i, err := parseStringIDs(c.Request().URL.Query()["id"])
@@ -529,14 +406,11 @@ func handleDeleteSubscribers(c echo.Context) error {
 			return echo.NewHTTPError(http.StatusBadRequest,
 			return echo.NewHTTPError(http.StatusBadRequest,
 				app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error()))
 				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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -554,14 +428,8 @@ func handleDeleteSubscribersByQuery(c echo.Context) error {
 		return err
 		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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -579,13 +447,8 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
 		return err
 		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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -608,25 +471,20 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
 	}
 	}
 
 
 	// Action.
 	// Action.
-	var stmt string
+	var err error
 	switch req.Action {
 	switch req.Action {
 	case "add":
 	case "add":
-		stmt = app.queries.AddSubscribersToListsByQuery
+		err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
 	case "remove":
 	case "remove":
-		stmt = app.queries.DeleteSubscriptionsByQuery
+		err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
 	case "unsubscribe":
 	case "unsubscribe":
-		stmt = app.queries.UnsubscribeSubscribersFromListsByQuery
+		err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
 	default:
 	default:
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
 		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 {
 	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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -639,16 +497,13 @@ func handleDeleteSubscriberBounces(c echo.Context) error {
 		pID = c.Param("id")
 		pID = c.Param("id")
 	)
 	)
 
 
-	id, _ := strconv.ParseInt(pID, 10, 64)
+	id, _ := strconv.Atoi(pID)
 	if id < 1 {
 	if id < 1 {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		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})
 	return c.JSON(http.StatusOK, okResp{true})
@@ -663,7 +518,8 @@ func handleExportSubscriberData(c echo.Context) error {
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
 		pID = c.Param("id")
 		pID = c.Param("id")
 	)
 	)
-	id, _ := strconv.ParseInt(pID, 10, 64)
+
+	id, _ := strconv.Atoi(pID)
 	if id < 1 {
 	if id < 1 {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		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)
 	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,
 // exportSubscriberData collates the data of a subscriber including profile,
 // subscriptions, campaign_views, link_clicks (if they're enabled in the config)
 // 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 returns a formatted, indented JSON payload. Either takes a numeric id
 // and an empty subUUID or takes 0 and a string subUUID.
 // 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
 		return data, nil, err
 	}
 	}
 
 
@@ -807,46 +570,8 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool,
 		app.log.Printf("error marshalling subscriber export data: %v", err)
 		app.log.Printf("error marshalling subscriber export data: %v", err)
 		return data, nil, 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
+	return data, b, nil
 }
 }
 
 
 // sanitizeSQLExp does basic sanitisation on arbitrary
 // sanitizeSQLExp does basic sanitisation on arbitrary
@@ -864,8 +589,8 @@ func sanitizeSQLExp(q string) string {
 	return q
 	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 {
 	if vals, ok := qp[param]; ok {
 		for _, v := range vals {
 		for _, v := range vals {
 			if v == "" {
 			if v == "" {
@@ -876,9 +601,46 @@ func getQueryInts(param string, qp url.Values) (pq.Int64Array, error) {
 			if err != nil {
 			if err != nil {
 				return nil, err
 				return nil, err
 			}
 			}
-			out = append(out, int64(listID))
+			out = append(out, listID)
 		}
 		}
 	}
 	}
 
 
 	return out, nil
 	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
+	}
+}

+ 25 - 66
cmd/templates.go

@@ -2,7 +2,6 @@ package main
 
 
 import (
 import (
 	"errors"
 	"errors"
-	"fmt"
 	"net/http"
 	"net/http"
 	"regexp"
 	"regexp"
 	"strconv"
 	"strconv"
@@ -34,33 +33,24 @@ var (
 func handleGetTemplates(c echo.Context) error {
 func handleGetTemplates(c echo.Context) error {
 	var (
 	var (
 		app = c.Get("app").(*App)
 		app = c.Get("app").(*App)
-		out []models.Template
 
 
 		id, _     = strconv.Atoi(c.Param("id"))
 		id, _     = strconv.Atoi(c.Param("id"))
-		single    = false
 		noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
 		noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
 	)
 	)
 
 
 	// Fetch one list.
 	// Fetch one list.
 	if id > 0 {
 	if id > 0 {
-		single = true
-	}
+		out, err := app.core.GetTemplate(id, noBody)
+		if err != nil {
+			return err
+		}
 
 
-	err := app.queries.GetTemplates.Select(&out, 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 c.JSON(http.StatusOK, okResp{out})
 	}
 	}
 
 
-	if len(out) == 0 {
-		return c.JSON(http.StatusOK, okResp{[]struct{}{}})
-	} else if single {
-		return c.JSON(http.StatusOK, okResp{out[0]})
+	out, err := app.core.GetTemplates(noBody)
+	if err != nil {
+		return err
 	}
 	}
 
 
 	return c.JSON(http.StatusOK, okResp{out})
 	return c.JSON(http.StatusOK, okResp{out})
@@ -69,11 +59,10 @@ func handleGetTemplates(c echo.Context) error {
 // handlePreviewTemplate renders the HTML preview of a template.
 // handlePreviewTemplate renders the HTML preview of a template.
 func handlePreviewTemplate(c echo.Context) error {
 func handlePreviewTemplate(c echo.Context) error {
 	var (
 	var (
-		app   = c.Get("app").(*App)
+		app = c.Get("app").(*App)
+
 		id, _ = strconv.Atoi(c.Param("id"))
 		id, _ = strconv.Atoi(c.Param("id"))
 		body  = c.FormValue("body")
 		body  = c.FormValue("body")
-
-		tpls []models.Template
 	)
 	)
 
 
 	if body != "" {
 	if body != "" {
@@ -86,18 +75,12 @@ func handlePreviewTemplate(c echo.Context) error {
 			return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 			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 {
 		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.
 	// Compile the template.
@@ -140,20 +123,13 @@ func handleCreateTemplate(c echo.Context) error {
 		return err
 		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.
 // handleUpdateTemplate handles template modification.
@@ -176,19 +152,13 @@ func handleUpdateTemplate(c echo.Context) error {
 		return echo.NewHTTPError(http.StatusBadRequest, err.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 {
 	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.
 // 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"))
 		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)
 	return handleGetTemplates(c)
@@ -223,16 +190,8 @@ func handleDeleteTemplate(c echo.Context) error {
 		return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
 		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})
 	return c.JSON(http.StatusOK, okResp{true})

+ 3 - 35
cmd/utils.go

@@ -1,15 +1,12 @@
 package main
 package main
 
 
 import (
 import (
-	"bytes"
 	"crypto/rand"
 	"crypto/rand"
 	"fmt"
 	"fmt"
 	"path/filepath"
 	"path/filepath"
 	"regexp"
 	"regexp"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
-
-	"github.com/lib/pq"
 )
 )
 
 
 var (
 var (
@@ -37,35 +34,6 @@ func makeFilename(fName string) string {
 	return filepath.Base(name)
 	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
 // makeMsgTpl takes a page title, heading, and message and returns
 // a msgTpl that can be rendered as an HTML view. This is used for
 // a msgTpl that can be rendered as an HTML view. This is used for
 // rendering arbitrary HTML views with error and success messages.
 // 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
 // parseStringIDs takes a slice of numeric string IDs and
 // parses each number into an int64 and returns a slice of the
 // parses each number into an int64 and returns a slice of the
 // resultant values.
 // 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 {
 	for _, v := range s {
-		i, err := strconv.ParseInt(v, 10, 64)
+		i, err := strconv.Atoi(v)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}

+ 1 - 1
frontend/cypress/integration/campaigns.js

@@ -196,7 +196,7 @@ describe('Campaigns', () => {
         cy.wait(250);
         cy.wait(250);
 
 
         // Verify the changes.
         // Verify the changes.
-        (function (n) {
+        (function(n) {
           cy.location('pathname').then((p) => {
           cy.location('pathname').then((p) => {
             cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1)}`).should((response) => {
             cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1)}`).should((response) => {
               const { data } = response.body;
               const { data } = response.body;

+ 66 - 0
internal/core/bounces.go

@@ -0,0 +1,66 @@
+package core
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/knadh/listmonk/models"
+	"github.com/labstack/echo/v4"
+	"github.com/lib/pq"
+)
+
+var bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"}
+
+// QueryBounces retrieves bounce entries based on the given params.
+func (c *Core) QueryBounces(campID, subID int, source, orderBy, order string, offset, limit int) ([]models.Bounce, error) {
+	if !strSliceContains(orderBy, bounceQuerySortFields) {
+		orderBy = "created_at"
+	}
+	if order != SortAsc && order != SortDesc {
+		order = SortDesc
+	}
+
+	out := []models.Bounce{}
+	stmt := fmt.Sprintf(c.q.QueryBounces, orderBy, order)
+	if err := c.db.Select(&out, stmt, 0, campID, subID, source, offset, limit); err != nil {
+		c.log.Printf("error fetching bounces: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// GetBounce retrieves bounce entries based on the given params.
+func (c *Core) GetBounce(id int) (models.Bounce, error) {
+	var out []models.Bounce
+	stmt := fmt.Sprintf(c.q.QueryBounces, "id", SortAsc)
+	if err := c.db.Select(&out, stmt, id, 0, 0, "", 0, 1); err != nil {
+		c.log.Printf("error fetching bounces: %v", err)
+		return models.Bounce{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
+	}
+
+	if len(out) == 0 {
+		return models.Bounce{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.bounce}"))
+
+	}
+
+	return out[0], nil
+}
+
+// DeleteBounce deletes a list.
+func (c *Core) DeleteBounce(id int) error {
+	return c.DeleteBounces([]int{id})
+}
+
+// DeleteBounces deletes multiple lists.
+func (c *Core) DeleteBounces(ids []int) error {
+	if _, err := c.q.DeleteBounces.Exec(pq.Array(ids)); err != nil {
+		c.log.Printf("error deleting lists: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
+	}
+	return nil
+}

+ 337 - 0
internal/core/campaigns.go

@@ -0,0 +1,337 @@
+package core
+
+import (
+	"database/sql"
+	"net/http"
+
+	"github.com/gofrs/uuid"
+	"github.com/jmoiron/sqlx"
+	"github.com/knadh/listmonk/models"
+	"github.com/labstack/echo/v4"
+	"github.com/lib/pq"
+)
+
+const (
+	CampaignAnalyticsViews   = "views"
+	CampaignAnalyticsClicks  = "clicks"
+	CampaignAnalyticsBounces = "bounces"
+)
+
+// QueryCampaigns retrieves campaigns optionally filtering them by
+// the given arbitrary query expression.
+func (c *Core) QueryCampaigns(searchStr string, statuses []string, orderBy, order string, offset, limit int) (models.Campaigns, error) {
+	queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns)
+
+	if statuses == nil {
+		statuses = []string{}
+	}
+
+	// Unsafe to ignore scanning fields not present in models.Campaigns.
+	var out models.Campaigns
+	if err := c.db.Select(&out, stmt, 0, pq.Array(statuses), queryStr, offset, limit); err != nil {
+		c.log.Printf("error fetching campaigns: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	for i := 0; i < len(out); i++ {
+		// Replace null tags.
+		if out[i].Tags == nil {
+			out[i].Tags = []string{}
+		}
+	}
+
+	// Lazy load stats.
+	if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
+		c.log.Printf("error fetching campaign stats: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// GetCampaign retrieves a campaign.
+func (c *Core) GetCampaign(id int, uuid string) (models.Campaign, error) {
+	// Unsafe to ignore scanning fields not present in models.Campaigns.
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	var out models.Campaigns
+	if err := c.q.GetCampaign.Select(&out, id, uu); err != nil {
+		// if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil {
+		c.log.Printf("error fetching campaign: %v", err)
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	if len(out) == 0 {
+		return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
+
+	}
+
+	for i := 0; i < len(out); i++ {
+		// Replace null tags.
+		if out[i].Tags == nil {
+			out[i].Tags = []string{}
+		}
+	}
+
+	// Lazy load stats.
+	if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
+		c.log.Printf("error fetching campaign stats: %v", err)
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	return out[0], nil
+}
+
+// GetCampaignForPreview retrieves a campaign with a template body.
+func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
+	var out models.Campaign
+	if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil {
+		if err == sql.ErrNoRows {
+			return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
+				c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
+		}
+
+		c.log.Printf("error fetching campaign: %v", err)
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// CreateCampaign creates a new campaign.
+func (c *Core) CreateCampaign(o models.Campaign, listIDs []int) (models.Campaign, error) {
+	uu, err := uuid.NewV4()
+	if err != nil {
+		c.log.Printf("error generating UUID: %v", err)
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
+	}
+
+	// Insert and read ID.
+	var newID int
+	if err := c.q.CreateCampaign.Get(&newID,
+		uu,
+		o.Type,
+		o.Name,
+		o.Subject,
+		o.FromEmail,
+		o.Body,
+		o.AltBody,
+		o.ContentType,
+		o.SendAt,
+		o.Headers,
+		pq.StringArray(normalizeTags(o.Tags)),
+		o.Messenger,
+		o.TemplateID,
+		pq.Array(listIDs),
+	); err != nil {
+		if err == sql.ErrNoRows {
+			return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
+		}
+
+		c.log.Printf("error creating campaign: %v", err)
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	out, err := c.GetCampaign(newID, "")
+	if err != nil {
+		return models.Campaign{}, err
+	}
+
+	return out, nil
+}
+
+// UpdateCampaign updates a campaign.
+func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, sendLater bool) (models.Campaign, error) {
+	_, err := c.q.UpdateCampaign.Exec(id,
+		o.Name,
+		o.Subject,
+		o.FromEmail,
+		o.Body,
+		o.AltBody,
+		o.ContentType,
+		o.SendAt,
+		sendLater,
+		o.Headers,
+		pq.StringArray(normalizeTags(o.Tags)),
+		o.Messenger,
+		o.TemplateID,
+		pq.Array(listIDs))
+	if err != nil {
+		c.log.Printf("error updating campaign: %v", err)
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	out, err := c.GetCampaign(id, "")
+	if err != nil {
+		return models.Campaign{}, err
+	}
+
+	return out, nil
+}
+
+// UpdateCampaignStatus updates a campaign's status, eg: draft to running.
+func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) {
+	cm, err := c.GetCampaign(id, "")
+	if err != nil {
+		return models.Campaign{}, err
+	}
+
+	errMsg := ""
+	switch status {
+	case models.CampaignStatusDraft:
+		if cm.Status != models.CampaignStatusScheduled {
+			errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft")
+		}
+	case models.CampaignStatusScheduled:
+		if cm.Status != models.CampaignStatusDraft {
+			errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled")
+		}
+		if !cm.SendAt.Valid {
+			errMsg = c.i18n.T("campaigns.needsSendAt")
+		}
+
+	case models.CampaignStatusRunning:
+		if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
+			errMsg = c.i18n.T("campaigns.onlyPausedDraft")
+		}
+	case models.CampaignStatusPaused:
+		if cm.Status != models.CampaignStatusRunning {
+			errMsg = c.i18n.T("campaigns.onlyActivePause")
+		}
+	case models.CampaignStatusCancelled:
+		if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
+			errMsg = c.i18n.T("campaigns.onlyActiveCancel")
+		}
+	}
+
+	if len(errMsg) > 0 {
+		return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg)
+	}
+
+	res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status)
+	if err != nil {
+		c.log.Printf("error updating campaign status: %v", err)
+
+		return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	if n, _ := res.RowsAffected(); n == 0 {
+		return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+
+	cm.Status = status
+	return cm, nil
+}
+
+// DeleteCampaign deletes a campaign.
+func (c *Core) DeleteCampaign(id int) error {
+	res, err := c.q.DeleteCampaign.Exec(id)
+	if err != nil {
+		c.log.Printf("error deleting campaign: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+
+	}
+
+	if n, _ := res.RowsAffected(); n == 0 {
+		return echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
+	}
+
+	return nil
+}
+
+// GetRunningCampaignStats returns the progress stats of running campaigns.
+func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
+	out := []models.CampaignStats{}
+	if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
+		if err == sql.ErrNoRows {
+			return nil, nil
+		}
+
+		c.log.Printf("error fetching campaign stats: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	} else if len(out) == 0 {
+		return nil, nil
+	}
+
+	return out, nil
+}
+
+func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) {
+	// Pick campaign view counts or click counts.
+	var stmt *sqlx.Stmt
+	switch typ {
+	case "views":
+		stmt = c.q.GetCampaignViewCounts
+	case "clicks":
+		stmt = c.q.GetCampaignClickCounts
+	case "bounces":
+		stmt = c.q.GetCampaignBounceCounts
+	default:
+		return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
+	}
+
+	if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) {
+		return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates"))
+	}
+
+	out := []models.CampaignAnalyticsCount{}
+	if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
+		c.log.Printf("error fetching campaign %s: %v", typ, err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs.
+func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) {
+	out := []models.CampaignAnalyticsLink{}
+	if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
+		c.log.Printf("error fetching campaign %s: %v", typ, err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// RegisterCampaignView registers a subscriber's view on a campaign.
+func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
+	if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
+		c.log.Printf("error registering campaign view: %s", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+	}
+	return nil
+}
+
+// RegisterCampaignLinkClick registers a subscriber's link click on a campaign.
+func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) error {
+	if _, err := c.q.RegisterLinkClick.Exec(linkUUID, campUUID, subUUID); err != nil {
+		if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
+			return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink"))
+		}
+
+		c.log.Printf("error registering link click: %s", err)
+		return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
+	}
+	return nil
+}

+ 150 - 0
internal/core/core.go

@@ -0,0 +1,150 @@
+// package core is the collection of re-usable functions that primarily provides data (DB / CRUD) operations
+// to the app. For instance, creating and mutating objects like lists, subscribers etc.
+// All such methods return an echo.HTTPError{} (which implements error.error) that can be directly returned
+// as a response to HTTP handlers without further processing.
+package core
+
+import (
+	"bytes"
+	"fmt"
+	"log"
+	"regexp"
+	"strings"
+
+	"github.com/jmoiron/sqlx"
+	"github.com/knadh/listmonk/internal/i18n"
+	"github.com/knadh/listmonk/models"
+	"github.com/lib/pq"
+)
+
+const (
+	SortAsc  = "asc"
+	SortDesc = "desc"
+)
+
+// Core represents the listmonk core with all shared, global functions.
+type Core struct {
+	h *Hooks
+
+	constants Constants
+	i18n      *i18n.I18n
+	db        *sqlx.DB
+	q         *models.Queries
+	log       *log.Logger
+}
+
+// Constants represents constant config.
+type Constants struct {
+	SendOptinConfirmation bool
+}
+
+// Hooks contains external function hooks that are required by the core package.
+type Hooks struct {
+	SendOptinConfirmation func(models.Subscriber, []int) (int, error)
+}
+
+// Opt contains the controllers required to start the core.
+type Opt struct {
+	Constants Constants
+	I18n      *i18n.I18n
+	DB        *sqlx.DB
+	Queries   *models.Queries
+	Log       *log.Logger
+}
+
+var (
+	regexFullTextQuery = regexp.MustCompile(`\s+`)
+	regexpSpaces       = regexp.MustCompile(`[\s]+`)
+	querySortFields    = []string{"name", "status", "created_at", "updated_at"}
+)
+
+// New returns a new instance of the core.
+func New(o *Opt, h *Hooks) *Core {
+	return &Core{
+		h:         h,
+		constants: o.Constants,
+		i18n:      o.I18n,
+		db:        o.DB,
+		q:         o.Queries,
+		log:       o.Log,
+	}
+}
+
+// Given an error, pqErrMsg will try to return pq error details
+// if it's a pq error.
+func pqErrMsg(err error) string {
+	if err, ok := err.(*pq.Error); ok {
+		if err.Detail != "" {
+			return fmt.Sprintf("%s. %s", err, err.Detail)
+		}
+	}
+	return err.Error()
+}
+
+// makeSearchQuery cleans an optional search string and prepares the
+// query SQL statement (string interpolated) and returns the
+// search query string along with the SQL expression.
+func makeSearchQuery(searchStr, orderBy, order, query string) (string, string) {
+	if searchStr != "" {
+		searchStr = `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%`
+	}
+
+	// Sort params.
+	if !strSliceContains(orderBy, querySortFields) {
+		orderBy = "created_at"
+	}
+	if order != SortAsc && order != SortDesc {
+		order = SortDesc
+	}
+
+	return searchStr, fmt.Sprintf(query, orderBy, order)
+}
+
+// strSliceContains checks if a string is present in the string slice.
+func strSliceContains(str string, sl []string) bool {
+	for _, s := range sl {
+		if s == str {
+			return true
+		}
+	}
+
+	return false
+}
+
+// normalizeTags takes a list of string tags and normalizes them by
+// lower casing and removing all special characters except for dashes.
+func normalizeTags(tags []string) []string {
+	var (
+		out  []string
+		dash = []byte("-")
+	)
+
+	for _, t := range tags {
+		rep := regexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash)
+
+		if len(rep) > 0 {
+			out = append(out, string(rep))
+		}
+	}
+	return out
+}
+
+// sanitizeSQLExp does basic sanitisation on arbitrary
+// SQL query expressions coming from the frontend.
+func sanitizeSQLExp(q string) string {
+	if len(q) == 0 {
+		return ""
+	}
+	q = strings.TrimSpace(q)
+
+	// Remove semicolon suffix.
+	if q[len(q)-1] == ';' {
+		q = q[:len(q)-1]
+	}
+	return q
+}
+
+// strHasLen checks if the given string has a length within min-max.
+func strHasLen(str string, min, max int) bool {
+	return len(str) >= min && len(str) <= max
+}

+ 30 - 0
internal/core/dashboard.go

@@ -0,0 +1,30 @@
+package core
+
+import (
+	"net/http"
+
+	"github.com/jmoiron/sqlx/types"
+	"github.com/labstack/echo/v4"
+)
+
+// GetDashboardCharts returns chart data points to render on the dashboard.
+func (c *Core) GetDashboardCharts() (types.JSONText, error) {
+	var out types.JSONText
+	if err := c.q.GetDashboardCharts.Get(&out); err != nil {
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// GetDashboardCounts returns stats counts to show on the dashboard.
+func (c *Core) GetDashboardCounts() (types.JSONText, error) {
+	var out types.JSONText
+	if err := c.q.GetDashboardCounts.Get(&out); err != nil {
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}

+ 133 - 0
internal/core/lists.go

@@ -0,0 +1,133 @@
+package core
+
+import (
+	"net/http"
+
+	"github.com/gofrs/uuid"
+	"github.com/knadh/listmonk/models"
+	"github.com/labstack/echo/v4"
+	"github.com/lib/pq"
+)
+
+// GetLists gets all lists optionally filtered by type.
+func (c *Core) GetLists(typ string) ([]models.List, error) {
+	out := []models.List{}
+
+	// TODO: remove orderBy
+	if err := c.q.GetLists.Select(&out, typ, "id"); err != nil {
+		c.log.Printf("error fetching lists: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// QueryLists gets multiple lists based on multiple query params.
+func (c *Core) QueryLists(searchStr, orderBy, order string, offset, limit int) ([]models.List, error) {
+	out := []models.List{}
+
+	queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists)
+
+	if err := c.db.Select(&out, stmt, 0, "", queryStr, offset, limit); err != nil {
+		c.log.Printf("error fetching lists: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// GetList gets a list by its ID or UUID.
+func (c *Core) GetList(id int, uuid string) (models.List, error) {
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	var out []models.List
+	queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists)
+	if err := c.db.Select(&out, stmt, id, uu, queryStr, 0, 1); err != nil {
+		c.log.Printf("error fetching lists: %v", err)
+		return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+	}
+
+	if len(out) == 1 {
+		return out[0], nil
+	}
+
+	return models.List{}, nil
+}
+
+// GetListsByOptin returns lists by optin type.
+func (c *Core) GetListsByOptin(ids []int, optinType string) ([]models.List, error) {
+	out := []models.List{}
+	if err := c.q.GetListsByOptin.Select(&out, optinType, pq.Array(ids), nil); err != nil {
+		c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// CreateList creates a new list.
+func (c *Core) CreateList(l models.List) (models.List, error) {
+	uu, err := uuid.NewV4()
+	if err != nil {
+		c.log.Printf("error generating UUID: %v", err)
+		return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
+	}
+
+	if l.Type == "" {
+		l.Type = models.ListTypePrivate
+	}
+	if l.Optin == "" {
+		l.Optin = models.ListOptinSingle
+	}
+
+	// Insert and read ID.
+	var newID int
+	l.UUID = uu.String()
+	if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags))); err != nil {
+		c.log.Printf("error creating list: %v", err)
+		return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
+	}
+
+	return c.GetList(newID, "")
+}
+
+// UpdateList updates a given list.
+func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
+	res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, pq.StringArray(normalizeTags(l.Tags)))
+	if err != nil {
+		c.log.Printf("error updating list: %v", err)
+		return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
+	}
+
+	if n, _ := res.RowsAffected(); n == 0 {
+		return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
+	}
+
+	return c.GetList(id, "")
+}
+
+// DeleteList deletes a list.
+func (c *Core) DeleteList(id int) error {
+	return c.DeleteLists([]int{id})
+}
+
+// DeleteLists deletes multiple lists.
+func (c *Core) DeleteLists(ids []int) error {
+	if _, err := c.q.DeleteLists.Exec(pq.Array(ids)); err != nil {
+		c.log.Printf("error deleting lists: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
+	}
+	return nil
+}

+ 77 - 0
internal/core/media.go

@@ -0,0 +1,77 @@
+package core
+
+import (
+	"net/http"
+
+	"github.com/gofrs/uuid"
+	"github.com/knadh/listmonk/internal/media"
+	"github.com/labstack/echo/v4"
+)
+
+// GetAllMedia returns all uploaded media.
+func (c *Core) GetAllMedia(provider string, s media.Store) ([]media.Media, error) {
+	out := []media.Media{}
+	if err := c.q.GetAllMedia.Select(&out, provider); err != nil {
+		return out, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching",
+				"name", "{globals.terms.media}", "error", pqErrMsg(err)))
+	}
+
+	for i := 0; i < len(out); i++ {
+		out[i].URL = s.Get(out[i].Filename)
+		out[i].ThumbURL = s.Get(out[i].Thumb)
+	}
+
+	return out, nil
+}
+
+// GetMedia returns a media item.
+func (c *Core) GetMedia(id int, uuid string, s media.Store) (media.Media, error) {
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	var out media.Media
+	if err := c.q.GetMedia.Get(&out, id, uu); err != nil {
+		return out, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
+	}
+
+	out.URL = s.Get(out.Filename)
+	out.ThumbURL = s.Get(out.Thumb)
+
+	return out, nil
+}
+
+// InsertMedia inserts a new media file into the DB.
+func (c *Core) InsertMedia(fileName, thumbName string, provider string, s media.Store) (media.Media, error) {
+	uu, err := uuid.NewV4()
+	if err != nil {
+		c.log.Printf("error generating UUID: %v", err)
+		return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
+	}
+
+	// Write to the DB.
+	var newID int
+	if err := c.q.InsertMedia.Get(&newID, uu, fileName, thumbName, provider); err != nil {
+		c.log.Printf("error inserting uploaded file to db: %v", err)
+		return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
+	}
+
+	return c.GetMedia(newID, "", s)
+}
+
+// DeleteMedia deletes a given media item and returns the filename of the deleted item.
+func (c *Core) DeleteMedia(id int) (string, error) {
+	var fname string
+	if err := c.q.DeleteMedia.Get(&fname, id); err != nil {
+		c.log.Printf("error inserting uploaded file to db: %v", err)
+		return "", echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
+	}
+
+	return fname, nil
+}

+ 50 - 0
internal/core/settings.go

@@ -0,0 +1,50 @@
+package core
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/jmoiron/sqlx/types"
+	"github.com/knadh/listmonk/models"
+	"github.com/labstack/echo/v4"
+)
+
+// GetSettings returns settings from the DB.
+func (c *Core) GetSettings() (models.Settings, error) {
+	var (
+		b   types.JSONText
+		out models.Settings
+	)
+
+	if err := c.q.GetSettings.Get(&b); err != nil {
+		return out, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching",
+				"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
+	}
+
+	// Unmarshal the settings and filter out sensitive fields.
+	if err := json.Unmarshal([]byte(b), &out); err != nil {
+		return out, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
+	}
+
+	return out, nil
+}
+
+// UpdateSettings updates settings.
+func (c *Core) UpdateSettings(s models.Settings) error {
+	// Marshal settings.
+	b, err := json.Marshal(s)
+	if err != nil {
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
+	}
+
+	// Update the settings in the DB.
+	if _, err := c.q.UpdateSettings.Exec(b); err != nil {
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}

+ 436 - 0
internal/core/subscribers.go

@@ -0,0 +1,436 @@
+package core
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/gofrs/uuid"
+	"github.com/knadh/listmonk/models"
+	"github.com/labstack/echo/v4"
+	"github.com/lib/pq"
+)
+
+var (
+	subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
+)
+
+// GetSubscriber fetches a subscriber by one of the given params.
+func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, error) {
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	var out models.Subscribers
+	if err := c.q.GetSubscriber.Select(&out, id, uu, email); err != nil {
+		c.log.Printf("error fetching subscriber: %v", err)
+		return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching",
+				"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
+	}
+	if len(out) == 0 {
+		return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
+	}
+	if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
+		c.log.Printf("error loading subscriber lists: %v", err)
+		return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching",
+				"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+	}
+
+	return out[0], nil
+}
+
+// GetSubscribersByEmail fetches a subscriber by one of the given params.
+func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscribers, error) {
+	var out models.Subscribers
+
+	if err := c.q.GetSubscribersByEmails.Select(&out, pq.Array(emails)); err != nil {
+		c.log.Printf("error fetching subscriber: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
+	}
+	if len(out) == 0 {
+		return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noKnownSubsToTest"))
+	}
+
+	if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
+		c.log.Printf("error loading subscriber lists: %v", err)
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// QuerySubscribers queries and returns paginated subscrribers based on the given params including the total count.
+func (c *Core) QuerySubscribers(query string, listIDs []int, order, orderBy string, offset, limit int) (models.Subscribers, int, error) {
+	// There's an arbitrary query condition.
+	cond := ""
+	if query != "" {
+		cond = " AND " + query
+	}
+
+	// Sort params.
+	if !strSliceContains(orderBy, subQuerySortFields) {
+		orderBy = "subscribers.id"
+	}
+	if order != SortAsc && order != SortDesc {
+		order = SortDesc
+	}
+
+	// Required for pq.Array()
+	if listIDs == nil {
+		listIDs = []int{}
+	}
+
+	// Create a readonly transaction that just does COUNT() to obtain the count of results
+	// and to ensure that the arbitrary query is indeed readonly.
+	stmt := fmt.Sprintf(c.q.QuerySubscribersCount, cond)
+	tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
+	if err != nil {
+		c.log.Printf("error preparing subscriber query: %v", err)
+		return nil, 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
+	}
+	defer tx.Rollback()
+
+	// Execute the readonly query and get the count of results.
+	total := 0
+	if err := tx.Get(&total, stmt, pq.Array(listIDs)); err != nil {
+		return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	// No results.
+	if total == 0 {
+		return models.Subscribers{}, 0, nil
+	}
+
+	// Run the query again and fetch the actual data. stmt is the raw SQL query.
+	var out models.Subscribers
+	stmt = fmt.Sprintf(c.q.QuerySubscribers, cond, orderBy, order)
+	if err := tx.Select(&out, stmt, pq.Array(listIDs), offset, limit); err != nil {
+		return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	// Lazy load lists for each subscriber.
+	if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
+		c.log.Printf("error fetching subscriber lists: %v", err)
+		return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return out, total, nil
+}
+
+// GetSubscriberLists returns a subscriber's lists based on the given conditions.
+func (c *Core) GetSubscriberLists(subID int, uuid string, listIDs []int, listUUIDs []string, subStatus string, listType string) ([]models.List, error) {
+	if listIDs == nil {
+		listIDs = []int{}
+	}
+	if listUUIDs == nil {
+		listUUIDs = []string{}
+	}
+
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	// Fetch double opt-in lists from the given list IDs.
+	// Get the list of subscription lists where the subscriber hasn't confirmed.
+	out := []models.List{}
+	if err := c.q.GetSubscriberLists.Select(&out, subID, uu, pq.Array(listIDs), pq.Array(listUUIDs), subStatus, listType); err != nil {
+		c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
+		return nil, err
+	}
+
+	return out, nil
+}
+
+// GetSubscriberProfileForExport returns the subscriber's profile data as a JSON exportable.
+// Get the subscriber's data. A single query that gets the profile, list subscriptions, campaign views,
+// and link clicks. Names of private lists are replaced with "Private list".
+func (c *Core) GetSubscriberProfileForExport(id int, uuid string) (models.SubscriberExportProfile, error) {
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	var out models.SubscriberExportProfile
+	if err := c.q.ExportSubscriberData.Get(&out, id, uu); err != nil {
+		c.log.Printf("error fetching subscriber export data: %v", err)
+
+		return models.SubscriberExportProfile{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
+	}
+
+	return out, nil
+}
+
+// ExportSubscribers returns an iterator function that provides lists of subscribers based
+// on the given criteria in an exportable form. The iterator function returned can be called
+// repeatedly until there are nil subscribers. It's an iterator because exports can be extremely
+// large and may have to be fetched in batches from the DB and streamed somewhere.
+func (c *Core) ExportSubscribers(query string, subIDs, listIDs []int, batchSize int) (func() ([]models.SubscriberExport, error), error) {
+	// There's an arbitrary query condition.
+	cond := ""
+	if query != "" {
+		cond = " AND " + query
+	}
+
+	stmt := fmt.Sprintf(c.q.QuerySubscribersForExport, cond)
+
+	// Verify that the arbitrary SQL search expression is read only.
+	if cond != "" {
+		tx, err := c.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
+		if err != nil {
+			c.log.Printf("error preparing subscriber query: %v", err)
+			return nil, echo.NewHTTPError(http.StatusBadRequest,
+				c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
+		}
+		defer tx.Rollback()
+
+		if _, err := tx.Query(stmt, nil, 0, nil, 1); err != nil {
+			return nil, echo.NewHTTPError(http.StatusBadRequest,
+				c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
+		}
+	}
+
+	if subIDs == nil {
+		subIDs = []int{}
+	}
+	if listIDs == nil {
+		listIDs = []int{}
+	}
+
+	// Prepare the actual query statement.
+	tx, err := c.db.Preparex(stmt)
+	if err != nil {
+		c.log.Printf("error preparing subscriber query: %v", err)
+		return nil, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
+	}
+
+	id := 0
+	return func() ([]models.SubscriberExport, error) {
+		var out []models.SubscriberExport
+		if err := tx.Select(&out, pq.Array(listIDs), id, pq.Array(subIDs), batchSize); err != nil {
+			c.log.Printf("error exporting subscribers by query: %v", err)
+			return nil, echo.NewHTTPError(http.StatusInternalServerError,
+				c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+		}
+		if len(out) == 0 {
+			return nil, nil
+		}
+
+		id = out[len(out)-1].ID
+		return out, nil
+	}, nil
+}
+
+// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
+// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation.
+// 1st bool = isNew?, 2nd bool = optinSent?
+func (c *Core) CreateSubscriber(sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm bool) (models.Subscriber, bool, bool, error) {
+	uu, err := uuid.NewV4()
+	if err != nil {
+		c.log.Printf("error generating UUID: %v", err)
+		return models.Subscriber{}, false, false, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
+	}
+	sub.UUID = uu.String()
+
+	var (
+		isNew     = true
+		subStatus = models.SubscriptionStatusUnconfirmed
+	)
+
+	if preconfirm {
+		subStatus = models.SubscriptionStatusConfirmed
+	}
+	if sub.Status == "" {
+		sub.Status = models.UserStatusEnabled
+	}
+
+	// For pq.Array()
+	if listIDs == nil {
+		listIDs = []int{}
+	}
+	if listUUIDs == nil {
+		listUUIDs = []string{}
+	}
+
+	if err = c.q.InsertSubscriber.Get(&sub.ID,
+		sub.UUID,
+		sub.Email,
+		strings.TrimSpace(sub.Name),
+		sub.Status,
+		sub.Attribs,
+		pq.Array(listIDs),
+		pq.Array(listUUIDs),
+		subStatus); err != nil {
+		if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
+			isNew = false
+		} else {
+			// return sub.Subscriber, errSubscriberExists
+			c.log.Printf("error inserting subscriber: %v", err)
+			return models.Subscriber{}, false, false, echo.NewHTTPError(http.StatusInternalServerError,
+				c.i18n.Ts("globals.messages.errorCreating",
+					"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
+		}
+	}
+
+	// Fetch the subscriber'out full data. If the subscriber already existed and wasn't
+	// created, the id will be empty. Fetch the details by e-mail then.
+	out, err := c.GetSubscriber(sub.ID, "", sub.Email)
+	if err != nil {
+		return models.Subscriber{}, false, false, err
+	}
+
+	hasOptin := false
+	if !preconfirm && c.constants.SendOptinConfirmation {
+		// Send a confirmation e-mail (if there are any double opt-in lists).
+		num, _ := c.h.SendOptinConfirmation(out, listIDs)
+		hasOptin = num > 0
+	}
+
+	return out, isNew, hasOptin, nil
+}
+
+// UpdateSubscriber updates a subscriber's properties.
+func (c *Core) UpdateSubscriber(id int, sub models.Subscriber, listIDs []int, preconfirm bool) (models.Subscriber, error) {
+	subStatus := models.SubscriptionStatusUnconfirmed
+	if preconfirm {
+		subStatus = models.SubscriptionStatusConfirmed
+	}
+
+	// Format raw JSON attributes.
+	attribs := []byte("{}")
+	if len(sub.Attribs) > 0 {
+		if b, err := json.Marshal(sub.Attribs); err != nil {
+			return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
+				c.i18n.Ts("globals.messages.errorUpdating",
+					"name", "{globals.terms.subscriber}", "error", err.Error()))
+		} else {
+			attribs = b
+		}
+	}
+
+	_, err := c.q.UpdateSubscriber.Exec(id,
+		sub.Email,
+		strings.TrimSpace(sub.Name),
+		sub.Status,
+		json.RawMessage(attribs),
+		pq.Array(listIDs),
+		subStatus)
+	if err != nil {
+		c.log.Printf("error updating subscriber: %v", err)
+		return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating",
+				"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
+	}
+
+	out, err := c.GetSubscriber(sub.ID, "", sub.Email)
+	if err != nil {
+		return models.Subscriber{}, err
+	}
+
+	return out, nil
+}
+
+// BlocklistSubscribers blocklists the given list of subscribers.
+func (c *Core) BlocklistSubscribers(subIDs []int) error {
+	if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil {
+		c.log.Printf("error blocklisting subscribers: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
+	}
+
+	return nil
+}
+
+// BlocklistSubscribersByQuery blocklists the given list of subscribers.
+func (c *Core) BlocklistSubscribersByQuery(query string, listIDs []int) error {
+	if err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.BlocklistSubscribersByQuery, listIDs, c.db); err != nil {
+		c.log.Printf("error blocklisting subscribers: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// DeleteSubscribers deletes the given list of subscribers.
+func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error {
+	if subIDs == nil {
+		subIDs = []int{}
+	}
+	if subUUIDs == nil {
+		subUUIDs = []string{}
+	}
+
+	if _, err := c.q.DeleteSubscribers.Exec(pq.Array(subIDs), pq.Array(subUUIDs)); err != nil {
+		c.log.Printf("error deleting subscribers: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// DeleteSubscribersByQuery deletes subscribers by a given arbitrary query expression.
+func (c *Core) DeleteSubscribersByQuery(query string, listIDs []int) error {
+	err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscribersByQuery, listIDs, c.db)
+	if err != nil {
+		c.log.Printf("error deleting subscribers: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return err
+}
+
+// UnsubscribeByCampaign unsubscibers a given subscriber from lists in a given campaign.
+func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist bool) error {
+	if _, err := c.q.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
+		c.log.Printf("error unsubscribing: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// ConfirmOptionSubscription confirms a subscriber's optin subscription.
+func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string) error {
+	if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs)); err != nil {
+		c.log.Printf("error confirming subscription: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// DeleteSubscriberBounces deletes the given list of subscribers.
+func (c *Core) DeleteSubscriberBounces(id int, uuid string) error {
+	var uu interface{}
+	if uuid != "" {
+		uu = uuid
+	}
+
+	if _, err := c.q.DeleteBouncesBySubscriber.Exec(id, uu); err != nil {
+		c.log.Printf("error deleting bounces: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.bounces}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}

+ 93 - 0
internal/core/subscriptions.go

@@ -0,0 +1,93 @@
+package core
+
+import (
+	"net/http"
+
+	"github.com/labstack/echo/v4"
+	"github.com/lib/pq"
+)
+
+// AddSubscriptions adds list subscriptions to subscribers.
+func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error {
+	if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil {
+		c.log.Printf("error adding subscriptions: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
+	}
+
+	return nil
+}
+
+// AddSubscriptionsByQuery adds list subscriptions to subscribers by a given arbitrary query expression.
+// sourceListIDs is the list of list IDs to filter the subscriber query with.
+func (c *Core) AddSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int) error {
+	if sourceListIDs == nil {
+		sourceListIDs = []int{}
+	}
+
+	err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, targetListIDs)
+	if err != nil {
+		c.log.Printf("error adding subscriptions by query: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// DeleteSubscriptions delete list subscriptions from subscribers.
+func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error {
+	if _, err := c.q.DeleteSubscriptions.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil {
+		c.log.Printf("error deleting subscriptions: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
+
+	}
+
+	return nil
+}
+
+// DeleteSubscriptionsByQuery deletes list subscriptions from subscribers by a given arbitrary query expression.
+// sourceListIDs is the list of list IDs to filter the subscriber query with.
+func (c *Core) DeleteSubscriptionsByQuery(query string, sourceListIDs, targetListIDs []int) error {
+	if sourceListIDs == nil {
+		sourceListIDs = []int{}
+	}
+
+	err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, targetListIDs)
+	if err != nil {
+		c.log.Printf("error deleting subscriptions by query: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// UnsubscribeLists sets list subscriptions to 'unsubscribed'.
+func (c *Core) UnsubscribeLists(subIDs, listIDs []int) error {
+	if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil {
+		c.log.Printf("error unsubscribing from lists: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
+	}
+
+	return nil
+}
+
+// UnsubscribeListsByQuery sets list subscriptions to 'ubsubscribed' by a given arbitrary query expression.
+// sourceListIDs is the list of list IDs to filter the subscriber query with.
+func (c *Core) UnsubscribeListsByQuery(query string, sourceListIDs, targetListIDs []int) error {
+	if sourceListIDs == nil {
+		sourceListIDs = []int{}
+	}
+
+	err := c.q.ExecSubscriberQueryTpl(sanitizeSQLExp(query), c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, targetListIDs)
+	if err != nil {
+		c.log.Printf("error unsubscriging from lists by query: %v", err)
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}

+ 87 - 0
internal/core/templates.go

@@ -0,0 +1,87 @@
+package core
+
+import (
+	"net/http"
+
+	"github.com/knadh/listmonk/models"
+	"github.com/labstack/echo/v4"
+)
+
+// GetTemplates retrieves all templates.
+func (c *Core) GetTemplates(noBody bool) ([]models.Template, error) {
+	out := []models.Template{}
+	if err := c.q.GetTemplates.Select(&out, 0, noBody); err != nil {
+		return nil, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
+	}
+
+	return out, nil
+}
+
+// GetTemplate retrieves a given template.
+func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
+	var out []models.Template
+	if err := c.q.GetTemplates.Select(&out, id, noBody); err != nil {
+		return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
+	}
+
+	if len(out) == 0 {
+		return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
+	}
+
+	return out[0], nil
+}
+
+// CreateTemplate creates a new template.
+func (c *Core) CreateTemplate(name string, body []byte) (models.Template, error) {
+	var newID int
+	if err := c.q.CreateTemplate.Get(&newID, name, body); err != nil {
+		return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
+	}
+
+	return c.GetTemplate(newID, false)
+}
+
+// UpdateTemplate updates a given template.
+func (c *Core) UpdateTemplate(id int, name string, body []byte) (models.Template, error) {
+	res, err := c.q.UpdateTemplate.Exec(id, name, body)
+	if err != nil {
+		return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
+	}
+
+	if n, _ := res.RowsAffected(); n == 0 {
+		return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
+			c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
+	}
+
+	return c.GetTemplate(id, false)
+}
+
+// SetDefaultTemplate sets a template as default.
+func (c *Core) SetDefaultTemplate(id int) error {
+	if _, err := c.q.SetDefaultTemplate.Exec(id); err != nil {
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
+	}
+
+	return nil
+}
+
+// DeleteTemplate deletes a given template.
+func (c *Core) DeleteTemplate(id int) error {
+	var delID int
+	if err := c.q.DeleteTemplate.Get(&delID, id); err != nil {
+		// TODO: Fix this. Deletes but always throws a "no result set" error.
+		return echo.NewHTTPError(http.StatusInternalServerError,
+			c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
+	}
+	if delID == 0 {
+		return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("templates.cantDeleteDefault"))
+	}
+
+	return nil
+}

+ 21 - 21
internal/subimporter/importer.go

@@ -527,7 +527,7 @@ func (s *Session) LoadCSV(srcPath string, delim rune) error {
 		sub.Email = row["email"]
 		sub.Email = row["email"]
 		sub.Name = row["name"]
 		sub.Name = row["name"]
 
 
-		sub, err = s.im.ValidateFields(sub)
+		sub, err = s.im.validateFields(sub)
 		if err != nil {
 		if err != nil {
 			s.log.Printf("skipping line %d: %s: %v", i, sub.Email, err)
 			s.log.Printf("skipping line %d: %s: %v", i, sub.Email, err)
 			continue
 			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,
 // SanitizeEmail validates and sanitizes an e-mail string and returns the lowercased,
 // e-mail component of an e-mail string.
 // e-mail component of an e-mail string.
 func (im *Importer) SanitizeEmail(email string) (string, error) {
 func (im *Importer) SanitizeEmail(email string) (string, error) {
@@ -616,6 +596,26 @@ func (im *Importer) SanitizeEmail(email string) (string, error) {
 	return em.Address, nil
 	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,
 // 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)
 // 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.
 // in the given CSV list.

+ 41 - 0
models/models.go

@@ -121,6 +121,16 @@ var regTplFuncs = []regTplFunc{
 // when a campaign's status changes.
 // when a campaign's status changes.
 type AdminNotifCallback func(subject string, data interface{}) error
 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.
 // Base holds common fields shared across models.
 type Base struct {
 type Base struct {
 	ID        int       `db:"id" json:"id"`
 	ID        int       `db:"id" json:"id"`
@@ -155,6 +165,15 @@ type subLists struct {
 	Lists        types.JSONText `db:"lists"`
 	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.
 // SubscriberAttribs is the map of key:value attributes of a subscriber.
 type SubscriberAttribs map[string]interface{}
 type SubscriberAttribs map[string]interface{}
 
 
@@ -246,6 +265,28 @@ type CampaignMeta struct {
 	Sent      int       `db:"sent" json:"sent"`
 	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.
 // Campaigns represents a slice of Campaigns.
 type Campaigns []Campaign
 type Campaigns []Campaign
 
 

+ 8 - 36
cmd/queries.go → models/queries.go

@@ -1,10 +1,9 @@
-package main
+package models
 
 
 import (
 import (
 	"context"
 	"context"
 	"database/sql"
 	"database/sql"
 	"fmt"
 	"fmt"
-	"time"
 
 
 	"github.com/jmoiron/sqlx"
 	"github.com/jmoiron/sqlx"
 	"github.com/lib/pq"
 	"github.com/lib/pq"
@@ -22,7 +21,6 @@ type Queries struct {
 	GetSubscribersByEmails          *sqlx.Stmt `query:"get-subscribers-by-emails"`
 	GetSubscribersByEmails          *sqlx.Stmt `query:"get-subscribers-by-emails"`
 	GetSubscriberLists              *sqlx.Stmt `query:"get-subscriber-lists"`
 	GetSubscriberLists              *sqlx.Stmt `query:"get-subscriber-lists"`
 	GetSubscriberListsLazy          *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
 	GetSubscriberListsLazy          *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
-	SubscriberExists                *sqlx.Stmt `query:"subscriber-exists"`
 	UpdateSubscriber                *sqlx.Stmt `query:"update-subscriber"`
 	UpdateSubscriber                *sqlx.Stmt `query:"update-subscriber"`
 	BlocklistSubscribers            *sqlx.Stmt `query:"blocklist-subscribers"`
 	BlocklistSubscribers            *sqlx.Stmt `query:"blocklist-subscribers"`
 	AddSubscribersToLists           *sqlx.Stmt `query:"add-subscribers-to-lists"`
 	AddSubscribersToLists           *sqlx.Stmt `query:"add-subscribers-to-lists"`
@@ -72,6 +70,7 @@ type Queries struct {
 	DeleteCampaign           *sqlx.Stmt `query:"delete-campaign"`
 	DeleteCampaign           *sqlx.Stmt `query:"delete-campaign"`
 
 
 	InsertMedia *sqlx.Stmt `query:"insert-media"`
 	InsertMedia *sqlx.Stmt `query:"insert-media"`
+	GetAllMedia *sqlx.Stmt `query:"get-all-media"`
 	GetMedia    *sqlx.Stmt `query:"get-media"`
 	GetMedia    *sqlx.Stmt `query:"get-media"`
 	DeleteMedia *sqlx.Stmt `query:"delete-media"`
 	DeleteMedia *sqlx.Stmt `query:"delete-media"`
 
 
@@ -94,39 +93,12 @@ type Queries struct {
 	DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
 	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
 // to filter subscribers from the subscribers table and prepares a query
 // out of it using the raw `query-subscribers-template` query template.
 // out of it using the raw `query-subscribers-template` query template.
 // While doing this, a readonly transaction is created and the query is
 // While doing this, a readonly transaction is created and the query is
 // dry run on it to ensure that it is indeed readonly.
 // 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})
 	tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
 	if err != nil {
 	if err != nil {
 		return "", err
 		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
 // compileSubscriberQueryTpl takes an arbitrary WHERE expressions and a subscriber
 // query template that depends on the filter (eg: delete by query, blocklist by query etc.)
 // query template that depends on the filter (eg: delete by query, blocklist by query etc.)
 // combines and executes them.
 // 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.
 	// Perform a dry run.
-	filterExp, err := q.compileSubscriberQueryTpl(exp, db)
+	filterExp, err := q.CompileSubscriberQueryTpl(exp, db)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	if len(listIDs) == 0 {
 	if len(listIDs) == 0 {
-		listIDs = pq.Int64Array{}
+		listIDs = []int{}
 	}
 	}
 
 
 	// First argument is the boolean indicating if the query is a dry run.
 	// 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 {
 	if _, err := db.Exec(fmt.Sprintf(tpl, filterExp), a...); err != nil {
 		return err
 		return err
 	}
 	}

+ 102 - 0
models/settings.go

@@ -0,0 +1,102 @@
+package models
+
+// Settings represents the app settings stored in the DB.
+type Settings struct {
+	AppRootURL            string   `json:"app.root_url"`
+	AppLogoURL            string   `json:"app.logo_url"`
+	AppFaviconURL         string   `json:"app.favicon_url"`
+	AppFromEmail          string   `json:"app.from_email"`
+	AppNotifyEmails       []string `json:"app.notify_emails"`
+	EnablePublicSubPage   bool     `json:"app.enable_public_subscription_page"`
+	SendOptinConfirmation bool     `json:"app.send_optin_confirmation"`
+	CheckUpdates          bool     `json:"app.check_updates"`
+	AppLang               string   `json:"app.lang"`
+
+	AppBatchSize     int `json:"app.batch_size"`
+	AppConcurrency   int `json:"app.concurrency"`
+	AppMaxSendErrors int `json:"app.max_send_errors"`
+	AppMessageRate   int `json:"app.message_rate"`
+
+	AppMessageSlidingWindow         bool   `json:"app.message_sliding_window"`
+	AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"`
+	AppMessageSlidingWindowRate     int    `json:"app.message_sliding_window_rate"`
+
+	PrivacyIndividualTracking bool     `json:"privacy.individual_tracking"`
+	PrivacyUnsubHeader        bool     `json:"privacy.unsubscribe_header"`
+	PrivacyAllowBlocklist     bool     `json:"privacy.allow_blocklist"`
+	PrivacyAllowExport        bool     `json:"privacy.allow_export"`
+	PrivacyAllowWipe          bool     `json:"privacy.allow_wipe"`
+	PrivacyExportable         []string `json:"privacy.exportable"`
+	DomainBlocklist           []string `json:"privacy.domain_blocklist"`
+
+	UploadProvider             string `json:"upload.provider"`
+	UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
+	UploadFilesystemUploadURI  string `json:"upload.filesystem.upload_uri"`
+	UploadS3URL                string `json:"upload.s3.url"`
+	UploadS3PublicURL          string `json:"upload.s3.public_url"`
+	UploadS3AwsAccessKeyID     string `json:"upload.s3.aws_access_key_id"`
+	UploadS3AwsDefaultRegion   string `json:"upload.s3.aws_default_region"`
+	UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"`
+	UploadS3Bucket             string `json:"upload.s3.bucket"`
+	UploadS3BucketDomain       string `json:"upload.s3.bucket_domain"`
+	UploadS3BucketPath         string `json:"upload.s3.bucket_path"`
+	UploadS3BucketType         string `json:"upload.s3.bucket_type"`
+	UploadS3Expiry             string `json:"upload.s3.expiry"`
+
+	SMTP []struct {
+		UUID          string              `json:"uuid"`
+		Enabled       bool                `json:"enabled"`
+		Host          string              `json:"host"`
+		HelloHostname string              `json:"hello_hostname"`
+		Port          int                 `json:"port"`
+		AuthProtocol  string              `json:"auth_protocol"`
+		Username      string              `json:"username"`
+		Password      string              `json:"password,omitempty"`
+		EmailHeaders  []map[string]string `json:"email_headers"`
+		MaxConns      int                 `json:"max_conns"`
+		MaxMsgRetries int                 `json:"max_msg_retries"`
+		IdleTimeout   string              `json:"idle_timeout"`
+		WaitTimeout   string              `json:"wait_timeout"`
+		TLSType       string              `json:"tls_type"`
+		TLSSkipVerify bool                `json:"tls_skip_verify"`
+	} `json:"smtp"`
+
+	Messengers []struct {
+		UUID          string `json:"uuid"`
+		Enabled       bool   `json:"enabled"`
+		Name          string `json:"name"`
+		RootURL       string `json:"root_url"`
+		Username      string `json:"username"`
+		Password      string `json:"password,omitempty"`
+		MaxConns      int    `json:"max_conns"`
+		Timeout       string `json:"timeout"`
+		MaxMsgRetries int    `json:"max_msg_retries"`
+	} `json:"messengers"`
+
+	BounceEnabled        bool   `json:"bounce.enabled"`
+	BounceEnableWebhooks bool   `json:"bounce.webhooks_enabled"`
+	BounceCount          int    `json:"bounce.count"`
+	BounceAction         string `json:"bounce.action"`
+	SESEnabled           bool   `json:"bounce.ses_enabled"`
+	SendgridEnabled      bool   `json:"bounce.sendgrid_enabled"`
+	SendgridKey          string `json:"bounce.sendgrid_key"`
+	BounceBoxes          []struct {
+		UUID          string `json:"uuid"`
+		Enabled       bool   `json:"enabled"`
+		Type          string `json:"type"`
+		Host          string `json:"host"`
+		Port          int    `json:"port"`
+		AuthProtocol  string `json:"auth_protocol"`
+		ReturnPath    string `json:"return_path"`
+		Username      string `json:"username"`
+		Password      string `json:"password,omitempty"`
+		TLSEnabled    bool   `json:"tls_enabled"`
+		TLSSkipVerify bool   `json:"tls_skip_verify"`
+		ScanInterval  string `json:"scan_interval"`
+	} `json:"bounce.mailboxes"`
+
+	AdminCustomCSS  string `json:"appearance.admin.custom_css"`
+	AdminCustomJS   string `json:"appearance.admin.custom_js"`
+	PublicCustomCSS string `json:"appearance.public.custom_css"`
+	PublicCustomJS  string `json:"appearance.public.custom_js"`
+}

+ 19 - 14
queries.sql

@@ -9,10 +9,6 @@ SELECT * FROM subscribers WHERE
         WHEN $3 != '' THEN email = $3
         WHEN $3 != '' THEN email = $3
     END;
     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
 -- name: get-subscribers-by-emails
 -- Get subscribers by emails.
 -- Get subscribers by emails.
 SELECT * FROM subscribers WHERE email=ANY($1);
 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)
     LEFT JOIN subscriber_lists ON (lists.id = subscriber_lists.list_id)
     WHERE subscriber_id = (SELECT id FROM sub)
     WHERE subscriber_id = (SELECT id FROM sub)
     -- Optional list IDs or UUIDs to filter.
     -- 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
           ELSE TRUE
     END)
     END)
     AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status END)
     AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status END)
@@ -64,7 +60,7 @@ WITH sub AS (
 ),
 ),
 listIDs AS (
 listIDs AS (
     SELECT id FROM lists WHERE
     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)
               ELSE uuid=ANY($7::UUID[]) END)
 ),
 ),
 subs AS (
 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
 -- name: query-lists
 WITH ls AS (
 WITH ls AS (
 	SELECT COUNT(*) OVER () AS total, lists.* FROM lists
 	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 (
 counts AS (
     SELECT list_id, JSON_OBJECT_AGG(status, subscriber_count) AS subscriber_statuses FROM (
     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
     ) AS lists
 FROM campaigns c
 FROM campaigns c
 WHERE ($1 = 0 OR id = $1)
 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)
     AND ($3 = '' OR CONCAT(name, subject) ILIKE $3)
 ORDER BY %s %s OFFSET $4 LIMIT (CASE WHEN $5 = 0 THEN NULL ELSE $5 END);
 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;
 UPDATE templates SET is_default=false WHERE id != $1;
 
 
 -- name: delete-template
 -- 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 that template to the default template instead.
 WITH tpl AS (
 WITH tpl AS (
     DELETE FROM templates WHERE id = $1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false RETURNING id
     DELETE FROM templates WHERE id = $1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false RETURNING id
@@ -781,11 +783,14 @@ UPDATE campaigns SET template_id = (SELECT id FROM def) WHERE (SELECT id FROM tp
 
 
 -- media
 -- media
 -- name: insert-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-media
+-- name: get-all-media
 SELECT * FROM media WHERE provider=$1 ORDER BY created_at DESC;
 SELECT * FROM media WHERE provider=$1 ORDER BY created_at DESC;
 
 
+-- name: get-media
+SELECT * FROM media WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
+
 -- name: delete-media
 -- name: delete-media
 DELETE FROM media WHERE id=$1 RETURNING filename;
 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;
 ORDER BY %s %s OFFSET $5 LIMIT $6;
 
 
 -- name: delete-bounces
 -- 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
 -- name: delete-bounces-by-subscriber
 WITH sub AS (
 WITH sub AS (