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.
This commit is contained in:
Kailash Nadh 2019-12-01 17:48:36 +05:30
parent bdd42b66c5
commit 871893a9d2
18 changed files with 425 additions and 96 deletions

View File

@ -0,0 +1,21 @@
{{ define "subscriber-optin" }}
{{ template "header" . }}
<h2>Confirm subscription</h2>
<p>Hi {{ .Subscriber.FirstName }},</p>
<p>You have been added to the following mailing lists:</p>
<ul>
{{ range $i, $l := .Lists }}
{{ if eq .Type "public" }}
<li>{{ .Name }}</li>
{{ else }}
<li>Private list</li>
{{ end }}
{{ end }}
</ul>
<p>Confirm your subscription by clicking the below button.</p>
<p>
<a href="{{ .OptinURL }}" class="button">Confirm subscription</a>
</p>
{{ template "footer" }}
{{ end }}

View File

@ -446,19 +446,16 @@ class Import extends React.PureComponent {
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>status,</span>
<span>attributes</span>
</code>
<code className="csv-row">
<span>user1@mail.com,</span>
<span>"User One",</span>
<span>enabled,</span>
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
</code>
<code className="csv-row">
<span>user2@mail.com,</span>
<span>"User Two",</span>
<span>blacklisted,</span>
<span>
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
</span>

View File

@ -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 {
</Select>
)}
</Form.Item>
<Form.Item
{...formItemLayout}
name="optin"
label="Opt-in"
extra="Double opt-in sends an e-mail to the subscriber asking for confirmation.
On Double opt-in lists, campaigns are only sent to confirmed subscribers."
>
{getFieldDecorator("optin", {
initialValue: record.optin ? record.optin : "single",
rules: [{ required: true }]
})(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="single">Single</Select.Option>
<Select.Option value="double">Double</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="Tags"
@ -239,16 +257,32 @@ class Lists extends React.PureComponent {
{
title: "Type",
dataIndex: "type",
width: "10%",
render: (type, _) => {
width: "15%",
render: (type, record) => {
let color = type === "private" ? "orange" : "green"
return <Tag color={color}>{type}</Tag>
return (
<div>
<p>
<Tag color={color}>{type}</Tag>
<Tag>{record.optin}</Tag>
</p>
{record.optin === cs.ListOptinDouble && (
<p className="text-small">
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in">
<Link to={`/campaigns/new?list_id=${record.id}`}>
<Icon type="rocket" /> Send opt-in campaign
</Link>
</Tooltip>
</p>
)}
</div>
)
}
},
{
title: "Subscribers",
dataIndex: "subscriber_count",
width: "15%",
width: "10%",
align: "center",
render: (text, record) => {
return (

View File

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

View File

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

View File

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

View File

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

44
main.go
View File

@ -33,6 +33,10 @@ type constants struct {
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"`
@ -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

View File

@ -63,7 +63,6 @@ type Message struct {
Subscriber *models.Subscriber
Body []byte
unsubURL string
from string
to string
}
@ -76,6 +75,7 @@ type Config struct {
FromEmail string
LinkTrackURL string
UnsubURL string
OptinURL string
ViewTrackURL string
}
@ -110,7 +110,6 @@ func (m *Manager) NewMessage(c *models.Campaign, s *models.Subscriber) *Message
from: c.FromEmail,
to: s.Email,
unsubURL: fmt.Sprintf(m.cfg.UnsubURL, c.UUID, s.UUID),
}
}
@ -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 == "" {

View File

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

View File

@ -2,25 +2,35 @@ package main
import (
"bytes"
"html/template"
"github.com/knadh/stuffbin"
)
const (
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
}

View File

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

View File

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

View File

@ -0,0 +1,28 @@
{{ define "optin" }}
{{ template "header" .}}
<section>
<h2>Confirm</h2>
<p>
You have been added to the following mailing lists:
</p>
<form method="post">
<ul>
{{ range $i, $l := .Data.Lists }}
<input type="hidden" name="l" value="{{ $l.UUID }}" />
{{ if eq $l.Type "public" }}
<li>{{ $l.Name }}</li>
{{ else }}
<li>Private list</li>
{{ end }}
{{ end }}
</ul>
<p>
<input type="hidden" name="confirm" value="true" />
<button type="submit" class="button" id="btn-unsub">Confirm subscription(s)</button>
</p>
</form>
</section>
{{ template "footer" .}}
{{ end }}

View File

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

View File

@ -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,20 +479,29 @@ 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 (

View File

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

View File

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