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" . }} +
Hi {{ .Subscriber.FirstName }},
+You have been added to the following mailing lists:
+Confirm your subscription by clicking the below button.
+ + +{{ 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:
+
+
+
+
+
+{{ 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 {