From 871893a9d2f17a456b0b8d2ca474b6e656cfe2ac Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sun, 1 Dec 2019 17:48:36 +0530 Subject: [PATCH] Add double opt-in support. - Lists can now be marked as single | double optin. - Insert subscribers to double opt-in lists send out a confirmation e-mail to the subscriber with a confirmation link. - Add `{{ OptinURL }}` to template functions. This is a breaking change. Adds a new field 'optin' to the lists table and changes how campaigns behave. Campaigns on double opt-in lists exclude subscribers who haven't explicitly confirmed subscriptions. Changes the structure and behaviour of how notification e-mail routines, including notif email template compilation, notification callbacks for campaign and bulk import completions. --- email-templates/subscriber-optin.html | 21 +++++ frontend/src/Import.js | 3 - frontend/src/Lists.js | 44 +++++++++-- frontend/src/constants.js | 3 + handlers.go | 2 + install.go | 3 +- lists.go | 3 +- main.go | 56 +++++++------ manager/manager.go | 18 +++-- models/models.go | 14 +++- notifications.go | 50 +++++++----- public.go | 83 +++++++++++++++++++- public/static/style.css | 13 ++- public/templates/optin.html | 28 +++++++ queries.go | 4 + queries.sql | 109 ++++++++++++++++++++------ schema.sql | 2 + subscribers.go | 65 +++++++++++++-- 18 files changed, 425 insertions(+), 96 deletions(-) create mode 100644 email-templates/subscriber-optin.html create mode 100644 public/templates/optin.html diff --git a/email-templates/subscriber-optin.html b/email-templates/subscriber-optin.html new file mode 100644 index 0000000..f81bff3 --- /dev/null +++ b/email-templates/subscriber-optin.html @@ -0,0 +1,21 @@ +{{ define "subscriber-optin" }} +{{ template "header" . }} +

Confirm subscription

+

Hi {{ .Subscriber.FirstName }},

+

You have been added to the following mailing lists:

+ +

Confirm your subscription by clicking the below button.

+

+ Confirm subscription +

+ +{{ template "footer" }} +{{ end }} diff --git a/frontend/src/Import.js b/frontend/src/Import.js index 360b5de..154a8c3 100644 --- a/frontend/src/Import.js +++ b/frontend/src/Import.js @@ -446,19 +446,16 @@ class Import extends React.PureComponent { email, name, - status, attributes user1@mail.com, "User One", - enabled, {'"{""age"": 32, ""city"": ""Bangalore""}"'} user2@mail.com, "User Two", - blacklisted, {'"{""age"": 25, ""occupation"": ""Time Traveller""}"'} diff --git a/frontend/src/Lists.js b/frontend/src/Lists.js index 6ee8c3d..6388de0 100644 --- a/frontend/src/Lists.js +++ b/frontend/src/Lists.js @@ -153,7 +153,8 @@ class CreateFormDef extends React.PureComponent { {...formItemLayout} name="type" label="Type" - extra="Public lists are open to the world to subscribe" + extra="Public lists are open to the world to subscribe and their + names may appear on public pages such as the subscription management page." > {getFieldDecorator("type", { initialValue: record.type ? record.type : "private", @@ -165,6 +166,23 @@ class CreateFormDef extends React.PureComponent { )} + + {getFieldDecorator("optin", { + initialValue: record.optin ? record.optin : "single", + rules: [{ required: true }] + })( + + )} + { + width: "15%", + render: (type, record) => { let color = type === "private" ? "orange" : "green" - return {type} + return ( +
+

+ {type} + {record.optin} +

+ {record.optin === cs.ListOptinDouble && ( +

+ + + Send opt-in campaign + + +

+ )} +
+ ) } }, { title: "Subscribers", dataIndex: "subscriber_count", - width: "15%", + width: "10%", align: "center", render: (text, record) => { return ( diff --git a/frontend/src/constants.js b/frontend/src/constants.js index f682e39..d63ad02 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -50,6 +50,9 @@ export const SubscriptionStatusConfirmed = "confirmed" export const SubscriptionStatusUnConfirmed = "unconfirmed" export const SubscriptionStatusUnsubscribed = "unsubscribed" +export const ListOptinSingle = "single" +export const ListOptinDouble = "double" + // API routes. export const Routes = { GetDashboarcStats: "/api/dashboard/stats", diff --git a/handlers.go b/handlers.go index cf2b825..52713e4 100644 --- a/handlers.go +++ b/handlers.go @@ -98,6 +98,8 @@ func registerHandlers(e *echo.Echo) { "campUUID", "subUUID")) e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID")) + e.GET("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) + e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData), "subUUID")) e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), diff --git a/install.go b/install.go index 8cd7f22..fb50e21 100644 --- a/install.go +++ b/install.go @@ -54,9 +54,10 @@ func install(app *App, qMap goyesql.Queries, prompt bool) { uuid.NewV4().String(), "Default list", models.ListTypePublic, + models.ListOptinSingle, pq.StringArray{"test"}, ); err != nil { - logger.Fatalf("Error creating superadmin user: %v", err) + logger.Fatalf("Error creating list: %v", err) } // Sample subscriber. diff --git a/lists.go b/lists.go index 9a0e9fe..0dcba56 100644 --- a/lists.go +++ b/lists.go @@ -92,6 +92,7 @@ func handleCreateList(c echo.Context) error { o.UUID, o.Name, o.Type, + o.Optin, pq.StringArray(normalizeTags(o.Tags))); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error creating list: %s", pqErrMsg(err))) @@ -120,7 +121,7 @@ func handleUpdateList(c echo.Context) error { return err } - res, err := app.Queries.UpdateList.Exec(id, o.Name, o.Type, pq.StringArray(normalizeTags(o.Tags))) + res, err := app.Queries.UpdateList.Exec(id, o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags))) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error updating list: %s", pqErrMsg(err))) diff --git a/main.go b/main.go index 1258b37..f95be66 100644 --- a/main.go +++ b/main.go @@ -30,12 +30,16 @@ import ( ) type constants struct { - RootURL string `koanf:"root"` - LogoURL string `koanf:"logo_url"` - FaviconURL string `koanf:"favicon_url"` - FromEmail string `koanf:"from_email"` - NotifyEmails []string `koanf:"notify_emails"` - Privacy privacyOptions `koanf:"privacy"` + RootURL string `koanf:"root"` + LogoURL string `koanf:"logo_url"` + FaviconURL string `koanf:"favicon_url"` + UnsubscribeURL string + LinkTrackURL string + ViewTrackURL string + OptinURL string + FromEmail string `koanf:"from_email"` + NotifyEmails []string `koanf:"notify_emails"` + Privacy privacyOptions `koanf:"privacy"` } type privacyOptions struct { @@ -286,8 +290,8 @@ func main() { app.Queries = q // Initialize the bulk subscriber importer. - importNotifCB := func(subject string, data map[string]interface{}) error { - go sendNotification(notifTplImport, subject, data, app) + importNotifCB := func(subject string, data interface{}) error { + go sendNotification(app.Constants.NotifyEmails, subject, notifTplImport, data, app) return nil } app.Importer = subimporter.New(q.UpsertSubscriber.Stmt, @@ -296,30 +300,38 @@ func main() { db.DB, importNotifCB) - // Read system e-mail templates. - notifTpls, err := stuffbin.ParseTemplatesGlob(nil, fs, "/email-templates/*.html") + // Prepare notification e-mail templates. + notifTpls, err := compileNotifTpls("/email-templates/*.html", fs, app) if err != nil { - logger.Fatalf("error loading system e-mail templates: %v", err) + logger.Fatalf("error loading e-mail notification templates: %v", err) } app.NotifTpls = notifTpls + // Static URLS. + // url.com/subscription/{campaign_uuid}/{subscriber_uuid} + c.UnsubscribeURL = fmt.Sprintf("%s/subscription/%%s/%%s", app.Constants.RootURL) + + // url.com/subscription/optin/{subscriber_uuid} + c.OptinURL = fmt.Sprintf("%s/subscription/optin/%%s?%%s", app.Constants.RootURL) + + // url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid} + c.LinkTrackURL = fmt.Sprintf("%s/link/%%s/%%s/%%s", app.Constants.RootURL) + + // url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png + c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL) + // Initialize the campaign manager. - campNotifCB := func(subject string, data map[string]interface{}) error { - return sendNotification(notifTplCampaign, subject, data, app) + campNotifCB := func(subject string, data interface{}) error { + return sendNotification(app.Constants.NotifyEmails, subject, notifTplCampaign, data, app) } m := manager.New(manager.Config{ Concurrency: ko.Int("app.concurrency"), MaxSendErrors: ko.Int("app.max_send_errors"), FromEmail: app.Constants.FromEmail, - - // url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid} - UnsubURL: fmt.Sprintf("%s/subscription/%%s/%%s", app.Constants.RootURL), - - // url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid} - LinkTrackURL: fmt.Sprintf("%s/link/%%s/%%s/%%s", app.Constants.RootURL), - - // url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png - ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL), + UnsubURL: c.UnsubscribeURL, + OptinURL: c.OptinURL, + LinkTrackURL: c.LinkTrackURL, + ViewTrackURL: c.ViewTrackURL, }, newManagerDB(q), campNotifCB, logger) app.Manager = m diff --git a/manager/manager.go b/manager/manager.go index 1a642b7..44ce726 100644 --- a/manager/manager.go +++ b/manager/manager.go @@ -63,9 +63,8 @@ type Message struct { Subscriber *models.Subscriber Body []byte - unsubURL string - from string - to string + from string + to string } // Config has parameters for configuring the manager. @@ -76,6 +75,7 @@ type Config struct { FromEmail string LinkTrackURL string UnsubURL string + OptinURL string ViewTrackURL string } @@ -108,9 +108,8 @@ func (m *Manager) NewMessage(c *models.Campaign, s *models.Subscriber) *Message Campaign: c, Subscriber: s, - from: c.FromEmail, - to: s.Email, - unsubURL: fmt.Sprintf(m.cfg.UnsubURL, c.UUID, s.UUID), + from: c.FromEmail, + to: s.Email, } } @@ -423,7 +422,12 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap { fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, msg.Subscriber.UUID))) }, "UnsubscribeURL": func(msg *Message) string { - return msg.unsubURL + return fmt.Sprintf(m.cfg.UnsubURL, c.UUID, msg.Subscriber.UUID) + }, + "OptinURL": func(msg *Message) string { + // Add list IDs. + // TODO: Show private lists list on optin e-mail + return fmt.Sprintf(m.cfg.OptinURL, msg.Subscriber.UUID, "") }, "Date": func(layout string) string { if layout == "" { diff --git a/models/models.go b/models/models.go index 17cab99..c9fe52a 100644 --- a/models/models.go +++ b/models/models.go @@ -23,6 +23,11 @@ const ( SubscriberStatusDisabled = "disabled" SubscriberStatusBlackListed = "blacklisted" + // Subscription. + SubscriptionStatusUnconfirmed = "unconfirmed" + SubscriptionStatusConfirmed = "confirmed" + SubscriptionStatusUnsubscribed = "unsubscribed" + // Campaign. CampaignStatusDraft = "draft" CampaignStatusScheduled = "scheduled" @@ -34,6 +39,8 @@ const ( // List. ListTypePrivate = "private" ListTypePublic = "public" + ListOptinSingle = "single" + ListOptinDouble = "double" // User. UserTypeSuperadmin = "superadmin" @@ -72,7 +79,7 @@ var regTplFuncs = []regTplFunc{ // AdminNotifCallback is a callback function that's called // when a campaign's status changes. -type AdminNotifCallback func(subject string, data map[string]interface{}) error +type AdminNotifCallback func(subject string, data interface{}) error // Base holds common fields shared across models. type Base struct { @@ -126,6 +133,7 @@ type List struct { UUID string `db:"uuid" json:"uuid"` Name string `db:"name" json:"name"` Type string `db:"type" json:"type"` + Optin string `db:"optin" json:"optin"` Tags pq.StringArray `db:"tags" json:"tags"` SubscriberCount int `db:"subscriber_count" json:"subscriber_count"` SubscriberID int `db:"subscriber_id" json:"-"` @@ -306,7 +314,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { // FirstName splits the name by spaces and returns the first chunk // of the name that's greater than 2 characters in length, assuming // that it is the subscriber's first name. -func (s *Subscriber) FirstName() string { +func (s Subscriber) FirstName() string { for _, s := range strings.Split(s.Name, " ") { if len(s) > 2 { return s @@ -319,7 +327,7 @@ func (s *Subscriber) FirstName() string { // LastName splits the name by spaces and returns the last chunk // of the name that's greater than 2 characters in length, assuming // that it is the subscriber's last name. -func (s *Subscriber) LastName() string { +func (s Subscriber) LastName() string { chunks := strings.Split(s.Name, " ") for i := len(chunks) - 1; i >= 0; i-- { chunk := chunks[i] diff --git a/notifications.go b/notifications.go index 7addec1..85b8523 100644 --- a/notifications.go +++ b/notifications.go @@ -2,25 +2,35 @@ package main import ( "bytes" + "html/template" + + "github.com/knadh/stuffbin" ) const ( - notifTplImport = "import-status" - notifTplCampaign = "campaign-status" + notifTplImport = "import-status" + notifTplCampaign = "campaign-status" + notifSubscriberOptin = "subscriber-optin" + notifSubscriberData = "subscriber-data" ) -// sendNotification sends out an e-mail notification to admins. -func sendNotification(tpl, subject string, data map[string]interface{}, app *App) error { - data["RootURL"] = app.Constants.RootURL +// notifData represents params commonly used across different notification +// templates. +type notifData struct { + RootURL string + LogoURL string +} +// sendNotification sends out an e-mail notification to admins. +func sendNotification(toEmails []string, subject, tplName string, data interface{}, app *App) error { var b bytes.Buffer - err := app.NotifTpls.ExecuteTemplate(&b, tpl, data) - if err != nil { + if err := app.NotifTpls.ExecuteTemplate(&b, tplName, data); err != nil { + app.Logger.Printf("error compiling notification template '%s': %v", tplName, err) return err } - err = app.Messenger.Push(app.Constants.FromEmail, - app.Constants.NotifyEmails, + err := app.Messenger.Push(app.Constants.FromEmail, + toEmails, subject, b.Bytes(), nil) @@ -28,21 +38,25 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App app.Logger.Printf("error sending admin notification (%s): %v", subject, err) return err } - return nil } -func getNotificationTemplate(tpl string, data map[string]interface{}, app *App) ([]byte, error) { - if data == nil { - data = make(map[string]interface{}) - } - data["RootURL"] = app.Constants.RootURL +// compileNotifTpls compiles and returns e-mail notification templates that are +// used for sending ad-hoc notifications to admins and subscribers. +func compileNotifTpls(path string, fs stuffbin.FileSystem, app *App) (*template.Template, error) { + // Register utility functions that the e-mail templates can use. + funcs := template.FuncMap{ + "RootURL": func() string { + return app.Constants.RootURL + }, + "LogoURL": func() string { + return app.Constants.LogoURL + }} - var b bytes.Buffer - err := app.NotifTpls.ExecuteTemplate(&b, tpl, data) + tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/email-templates/*.html") if err != nil { return nil, err } - return b.Bytes(), err + return tpl, err } diff --git a/public.go b/public.go index d210357..c4a6c49 100644 --- a/public.go +++ b/public.go @@ -10,6 +10,7 @@ import ( "strconv" "github.com/knadh/listmonk/messenger" + "github.com/knadh/listmonk/models" "github.com/labstack/echo" "github.com/lib/pq" ) @@ -44,6 +45,13 @@ type unsubTpl struct { AllowWipe bool } +type optinTpl struct { + publicTpl + SubUUID string + ListUUIDs []string `query:"l" form:"l"` + Lists []models.List `query:"-" form:"-"` +} + type msgTpl struct { publicTpl MessageTitle string @@ -102,6 +110,73 @@ func handleSubscriptionPage(c echo.Context) error { return c.Render(http.StatusOK, "subscription", out) } +// handleOptinPage handles a double opt-in confirmation from subscribers. +func handleOptinPage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + subUUID = c.Param("subUUID") + confirm, _ = strconv.ParseBool(c.FormValue("confirm")) + out = optinTpl{} + ) + out.SubUUID = subUUID + out.Title = "Confirm subscriptions" + out.SubUUID = subUUID + + // Get and validate fields. + if err := c.Bind(&out); err != nil { + return err + } + + // Validate list UUIDs if there are incoming UUIDs in the request. + if len(out.ListUUIDs) > 0 { + for _, l := range out.ListUUIDs { + if !reUUID.MatchString(l) { + return c.Render(http.StatusBadRequest, "message", + makeMsgTpl("Invalid request", "", + `One or more UUIDs in the request are invalid.`)) + } + } + + // Get lists by UUIDs. + if err := app.Queries.GetListsByUUID.Select(&out.Lists, pq.StringArray(out.ListUUIDs)); err != nil { + app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err)) + return c.Render(http.StatusInternalServerError, "message", + makeMsgTpl("Error", "", + `Error fetching lists. Please retry.`)) + } + } else { + // Otherwise, get the list of all unconfirmed lists for the subscriber. + if err := app.Queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, models.SubscriptionStatusUnconfirmed); err != nil { + app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err)) + return c.Render(http.StatusInternalServerError, "message", + makeMsgTpl("Error", "", + `Error fetching lists. Please retry.`)) + } + } + + // There are no lists to confirm. + if len(out.Lists) == 0 { + return c.Render(http.StatusInternalServerError, "message", + makeMsgTpl("No subscriptions", "", + `There are no subscriptions to confirm.`)) + } + + // Confirm. + if confirm { + if _, err := app.Queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil { + app.Logger.Printf("error unsubscribing: %v", err) + return c.Render(http.StatusInternalServerError, "message", + makeMsgTpl("Error", "", + `Error processing request. Please retry.`)) + } + return c.Render(http.StatusOK, "message", + makeMsgTpl("Confirmed", "", + `Your subscriptions have been confirmed.`)) + } + + return c.Render(http.StatusOK, "optin", out) +} + // handleLinkRedirect handles link UUID to real link redirection. func handleLinkRedirect(c echo.Context) error { var ( @@ -166,9 +241,9 @@ func handleSelfExportSubscriberData(c echo.Context) error { } // Send the data out to the subscriber as an atachment. - msg, err := getNotificationTemplate("subscriber-data", nil, app) - if err != nil { - app.Logger.Printf("error preparing subscriber data e-mail template: %s", err) + var msg bytes.Buffer + if err := app.NotifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil { + app.Logger.Printf("error compiling notification template '%s': %v", notifSubscriberData, err) return c.Render(http.StatusInternalServerError, "message", makeMsgTpl("Error preparing data", "", "There was an error preparing your data. Please try later.")) @@ -178,7 +253,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { if err := app.Messenger.Push(app.Constants.FromEmail, []string{data.Email}, "Your profile data", - msg, + msg.Bytes(), []*messenger.Attachment{ &messenger.Attachment{ Name: fname, diff --git a/public/static/style.css b/public/static/style.css index 04de28b..09db340 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -175,6 +175,11 @@ } } /*# sourceMappingURL=dist/flexit.min.css.map */ +html, body { + padding: 0; + margin: 0; + min-width: 320px; +} body { background: #f9f9f9; font-family: "Open Sans", "Helvetica Neue", sans-serif; @@ -235,7 +240,9 @@ section { } .header { - margin-bottom: 60px; + border-bottom: 1px solid #eee; + padding-bottom: 15px; + margin-bottom: 30px; } .header .logo img { width: auto; @@ -266,8 +273,6 @@ section { @media screen and (max-width: 650px) { .wrap { margin: 0; - } - .header { - margin-bottom: 30px; + padding: 30px; } } diff --git a/public/templates/optin.html b/public/templates/optin.html new file mode 100644 index 0000000..71a5c1f --- /dev/null +++ b/public/templates/optin.html @@ -0,0 +1,28 @@ +{{ define "optin" }} +{{ template "header" .}} +
+

Confirm

+

+ You have been added to the following mailing lists: +

+ +
+
    + {{ range $i, $l := .Data.Lists }} + + {{ if eq $l.Type "public" }} +
  • {{ $l.Name }}
  • + {{ else }} +
  • Private list
  • + {{ end }} + {{ end }} +
+

+ + +

+
+
+ +{{ template "footer" .}} +{{ end }} \ No newline at end of file diff --git a/queries.go b/queries.go index b15e224..08634f7 100644 --- a/queries.go +++ b/queries.go @@ -19,11 +19,13 @@ type Queries struct { GetSubscriber *sqlx.Stmt `query:"get-subscriber"` GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` + GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"` SubscriberExists *sqlx.Stmt `query:"subscriber-exists"` UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"` BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"` AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"` DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"` + ConfirmSubscriptionOptin *sqlx.Stmt `query:"confirm-subscription-optin"` UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"` DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"` Unsubscribe *sqlx.Stmt `query:"unsubscribe"` @@ -40,6 +42,8 @@ type Queries struct { CreateList *sqlx.Stmt `query:"create-list"` GetLists *sqlx.Stmt `query:"get-lists"` + GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"` + GetListsByUUID *sqlx.Stmt `query:"get-lists-by-uuid"` UpdateList *sqlx.Stmt `query:"update-list"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` DeleteLists *sqlx.Stmt `query:"delete-lists"` diff --git a/queries.sql b/queries.sql index 2ae18d0..9cd4f01 100644 --- a/queries.sql +++ b/queries.sql @@ -12,6 +12,15 @@ SELECT exists (SELECT true FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 SELECT * FROM subscribers WHERE email=ANY($1); -- name: get-subscriber-lists +WITH sub AS ( + SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END +) +SELECT * FROM lists + LEFT JOIN subscriber_lists ON (lists.id = subscriber_lists.list_id) + WHERE subscriber_id = (SELECT id FROM sub) + AND (CASE WHEN $3 != '' THEN subscriber_lists.status = $3::subscription_status END); + +-- name: get-subscriber-lists-lazy -- Get lists associations of subscribers given a list of subscriber IDs. -- This query is used to lazy load given a list of subscriber IDs. -- The query returns results in the same order as the given subscriber IDs, and for non-existent subscriber IDs, @@ -130,6 +139,16 @@ INSERT INTO subscriber_lists (subscriber_id, list_id) DELETE FROM subscriber_lists WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b); +-- name: confirm-subscription-optin +WITH subID AS ( + SELECT id FROM subscribers WHERE uuid = $1::UUID +), +listIDs AS ( + SELECT id FROM lists WHERE uuid = ANY($2::UUID[]) +) +UPDATE subscriber_lists SET status='confirmed', updated_at=NOW() + WHERE subscriber_id = (SELECT id FROM subID) AND list_id = ANY(SELECT id FROM listIDs); + -- name: unsubscribe-subscribers-from-lists UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW() WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b); @@ -275,14 +294,21 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id) WHERE ($1 = 0 OR id = $1) GROUP BY lists.id ORDER BY lists.created_at OFFSET $2 LIMIT (CASE WHEN $3 = 0 THEN NULL ELSE $3 END); +-- name: get-lists-by-optin +SELECT * FROM lists WHERE optin=$1::list_optin AND id = ANY($2::INT[]) ORDER BY name; + +-- name: get-lists-by-uuid +SELECT * FROM lists WHERE uuid = ANY($1::UUID[]) ORDER BY name; + -- name: create-list -INSERT INTO lists (uuid, name, type, tags) VALUES($1, $2, $3, $4) RETURNING id; +INSERT INTO lists (uuid, name, type, optin, tags) VALUES($1, $2, $3, $4, $5) RETURNING id; -- name: update-list UPDATE lists SET name=(CASE WHEN $2 != '' THEN $2 ELSE name END), type=(CASE WHEN $3 != '' THEN $3::list_type ELSE type END), - tags=(CASE WHEN ARRAY_LENGTH($4::VARCHAR(100)[], 1) > 0 THEN $4 ELSE tags END), + optin=(CASE WHEN $4 != '' THEN $4::list_optin ELSE optin END), + tags=(CASE WHEN ARRAY_LENGTH($5::VARCHAR(100)[], 1) > 0 THEN $5 ELSE tags END), updated_at=NOW() WHERE id = $1; @@ -296,10 +322,25 @@ DELETE FROM lists WHERE id = ALL($1); -- campaigns -- name: create-campaign -- This creates the campaign and inserts campaign_lists relationships. -WITH counts AS ( +WITH campLists AS ( + -- Get the list_ids and their optin statuses for the campaigns found in the previous step. + SELECT id AS list_id, campaign_id, optin FROM lists + INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id) + WHERE id=ANY($11::INT[]) +), +counts AS ( SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id FROM subscribers - LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id) + LEFT JOIN campLists ON (campLists.campaign_id = ANY($11::INT[])) + LEFT JOIN subscriber_lists ON ( + subscriber_lists.status != 'unsubscribed' AND + subscribers.id = subscriber_lists.subscriber_id AND + subscriber_lists.list_id = campLists.list_id AND + + -- For double opt-in lists, consider only 'confirmed' subscriptions. For single opt-ins, + -- any status except for 'unsubscribed' (already excluded above) works. + (CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END) + ) WHERE subscriber_lists.list_id=ANY($11::INT[]) AND subscribers.status='enabled' ), @@ -398,17 +439,32 @@ WITH camps AS ( WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW())) AND NOT(campaigns.id = ANY($1::INT[])) ), -counts AS ( - -- For each campaign above, get the total number of subscribers and the max_subscriber_id across all its lists. - SELECT id AS campaign_id, COUNT(subscriber_lists.subscriber_id) AS to_send, - COALESCE(MAX(subscriber_lists.subscriber_id), 0) AS max_subscriber_id FROM camps - LEFT JOIN campaign_lists ON (campaign_lists.campaign_id = camps.id) - LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = campaign_lists.list_id AND subscriber_lists.status != 'unsubscribed') +campLists AS ( + -- Get the list_ids and their optin statuses for the campaigns found in the previous step. + SELECT id AS list_id, campaign_id, optin FROM lists + INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id) WHERE campaign_lists.campaign_id = ANY(SELECT id FROM camps) +), +counts AS ( + -- For each campaign above, get the total number of subscribers and the max_subscriber_id + -- across all its lists. + SELECT id AS campaign_id, + COUNT(DISTINCT(subscriber_lists.subscriber_id)) AS to_send, + COALESCE(MAX(subscriber_lists.subscriber_id), 0) AS max_subscriber_id + FROM camps + LEFT JOIN campLists ON (campLists.campaign_id = camps.id) + LEFT JOIN subscriber_lists ON ( + subscriber_lists.status != 'unsubscribed' AND + subscriber_lists.list_id = campLists.list_id AND + + -- For double opt-in lists, consider only 'confirmed' subscriptions. For single opt-ins, + -- any status except for 'unsubscribed' (already excluded above) works. + (CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END) + ) GROUP BY camps.id ), u AS ( - -- For each campaign above, update the to_send count. + -- For each campaign, update the to_send count and set the max_subscriber_id. UPDATE campaigns AS ca SET to_send = co.to_send, status = (CASE WHEN status != 'running' THEN 'running' ELSE status END), @@ -423,27 +479,36 @@ SELECT * FROM camps; -- Returns a batch of subscribers in a given campaign starting from the last checkpoint -- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means -- every fetch returns a new batch of subscribers until all rows are exhausted. -WITH camp AS ( +WITH camps AS ( SELECT last_subscriber_id, max_subscriber_id FROM campaigns WHERE id=$1 AND status='running' ), +campLists AS ( + SELECT id AS list_id, optin FROM lists + INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id) + WHERE campaign_lists.campaign_id = $1 +), subs AS ( - SELECT DISTINCT ON(id) id AS uniq_id, * FROM subscribers - LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id AND subscriber_lists.status != 'unsubscribed') - WHERE subscriber_lists.list_id=ANY( - SELECT list_id FROM campaign_lists where campaign_id=$1 AND list_id IS NOT NULL + SELECT DISTINCT ON(subscribers.id) id AS uniq_id, subscribers.* FROM subscriber_lists + INNER JOIN campLists ON ( + campLists.list_id = subscriber_lists.list_id ) - AND subscribers.status != 'blacklisted' - AND id > (SELECT last_subscriber_id FROM camp) - AND id <= (SELECT max_subscriber_id FROM camp) + INNER JOIN subscribers ON ( + subscribers.status != 'blacklisted' AND + subscribers.id = subscriber_lists.subscriber_id AND + (CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END) + ) + WHERE subscriber_lists.status != 'unsubscribed' AND + id > (SELECT last_subscriber_id FROM camps) AND + id <= (SELECT max_subscriber_id FROM camps) ORDER BY id LIMIT $2 ), u AS ( UPDATE campaigns - SET last_subscriber_id=(SELECT MAX(id) FROM subs), - sent=sent + (SELECT COUNT(id) FROM subs), - updated_at=NOW() + SET last_subscriber_id = (SELECT MAX(id) FROM subs), + sent = sent + (SELECT COUNT(id) FROM subs), + updated_at = NOW() WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1 ) SELECT * FROM subs; diff --git a/schema.sql b/schema.sql index ddd0736..bf13fbe 100644 --- a/schema.sql +++ b/schema.sql @@ -1,4 +1,5 @@ DROP TYPE IF EXISTS list_type CASCADE; CREATE TYPE list_type AS ENUM ('public', 'private', 'temporary'); +DROP TYPE IF EXISTS list_optin CASCADE; CREATE TYPE list_optin AS ENUM ('single', 'double'); DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS ENUM ('enabled', 'disabled', 'blacklisted'); DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed'); DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished'); @@ -28,6 +29,7 @@ CREATE TABLE lists ( uuid uuid NOT NULL UNIQUE, name TEXT NOT NULL, type list_type NOT NULL, + optin list_optin NOT NULL DEFAULT 'single', tags VARCHAR(100)[], created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), diff --git a/subscribers.go b/subscribers.go index 3fb67fa..86b745d 100644 --- a/subscribers.go +++ b/subscribers.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" @@ -46,6 +47,14 @@ type subProfileData struct { LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"` } +// subOptin contains the data that's passed to the double opt-in e-mail template. +type subOptin struct { + *models.Subscriber + + OptinURL string + Lists []models.List +} + var dummySubscriber = models.Subscriber{ Email: "dummy@listmonk.app", Name: "Dummy Subscriber", @@ -73,7 +82,7 @@ func handleGetSubscriber(c echo.Context) error { if len(out) == 0 { return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.") } - if err := out.LoadLists(app.Queries.GetSubscriberLists); err != nil { + if err := out.LoadLists(app.Queries.GetSubscriberListsLazy); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Error loading lists for subscriber.") } @@ -123,7 +132,7 @@ func handleQuerySubscribers(c echo.Context) error { } // Lazy load lists for each subscriber. - if err := out.Results.LoadLists(app.Queries.GetSubscriberLists); err != nil { + if err := out.Results.LoadLists(app.Queries.GetSubscriberListsLazy); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err))) } @@ -157,10 +166,14 @@ func handleCreateSubscriber(c echo.Context) error { } // Insert and read ID. - var newID int + var ( + newID int + email = strings.ToLower(strings.TrimSpace(req.Email)) + ) + req.UUID = uuid.NewV4().String() err := app.Queries.InsertSubscriber.Get(&newID, - uuid.NewV4(), - strings.ToLower(strings.TrimSpace(req.Email)), + req.UUID, + email, strings.TrimSpace(req.Name), req.Status, req.Attribs, @@ -169,11 +182,13 @@ func handleCreateSubscriber(c echo.Context) error { if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.") } - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error creating subscriber: %v", err)) } + // If the lists are double-optins, send confirmation e-mails. + go sendOptinConfirmation(req.Subscriber, []int64(req.Lists), app) + // Hand over to the GET handler to return the last insertion. c.SetParamNames("id") c.SetParamValues(fmt.Sprintf("%d", newID)) @@ -503,6 +518,44 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, return data, b, nil } +// sendOptinConfirmation sends double opt-in confirmation e-mails to a subscriber +// if at least one of the given listIDs is set to optin=double +func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) error { + var lists []models.List + + // Fetch double opt-in lists from the given list IDs. + err := app.Queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(listIDs)) + if err != nil { + app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err)) + return err + } + + // None. + if len(lists) == 0 { + return 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()) + + // Send the e-mail. + if err := sendNotification([]string{sub.Email}, + "Confirm subscription", + notifSubscriberOptin, out, app); err != nil { + app.Logger.Printf("error e-mailing subscriber profile: %s", err) + return err + } + + return nil +} + // sanitizeSQLExp does basic sanitisation on arbitrary // SQL query expressions coming from the frontend. func sanitizeSQLExp(q string) string {