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"> <code className="csv-headers">
<span>email,</span> <span>email,</span>
<span>name,</span> <span>name,</span>
<span>status,</span>
<span>attributes</span> <span>attributes</span>
</code> </code>
<code className="csv-row"> <code className="csv-row">
<span>user1@mail.com,</span> <span>user1@mail.com,</span>
<span>"User One",</span> <span>"User One",</span>
<span>enabled,</span>
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span> <span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
</code> </code>
<code className="csv-row"> <code className="csv-row">
<span>user2@mail.com,</span> <span>user2@mail.com,</span>
<span>"User Two",</span> <span>"User Two",</span>
<span>blacklisted,</span>
<span> <span>
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'} {'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
</span> </span>

View File

@ -153,7 +153,8 @@ class CreateFormDef extends React.PureComponent {
{...formItemLayout} {...formItemLayout}
name="type" name="type"
label="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", { {getFieldDecorator("type", {
initialValue: record.type ? record.type : "private", initialValue: record.type ? record.type : "private",
@ -165,6 +166,23 @@ class CreateFormDef extends React.PureComponent {
</Select> </Select>
)} )}
</Form.Item> </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 <Form.Item
{...formItemLayout} {...formItemLayout}
label="Tags" label="Tags"
@ -239,16 +257,32 @@ class Lists extends React.PureComponent {
{ {
title: "Type", title: "Type",
dataIndex: "type", dataIndex: "type",
width: "10%", width: "15%",
render: (type, _) => { render: (type, record) => {
let color = type === "private" ? "orange" : "green" 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", title: "Subscribers",
dataIndex: "subscriber_count", dataIndex: "subscriber_count",
width: "15%", width: "10%",
align: "center", align: "center",
render: (text, record) => { render: (text, record) => {
return ( return (

View File

@ -50,6 +50,9 @@ export const SubscriptionStatusConfirmed = "confirmed"
export const SubscriptionStatusUnConfirmed = "unconfirmed" export const SubscriptionStatusUnConfirmed = "unconfirmed"
export const SubscriptionStatusUnsubscribed = "unsubscribed" export const SubscriptionStatusUnsubscribed = "unsubscribed"
export const ListOptinSingle = "single"
export const ListOptinDouble = "double"
// API routes. // API routes.
export const Routes = { export const Routes = {
GetDashboarcStats: "/api/dashboard/stats", GetDashboarcStats: "/api/dashboard/stats",

View File

@ -98,6 +98,8 @@ func registerHandlers(e *echo.Echo) {
"campUUID", "subUUID")) "campUUID", "subUUID"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID")) "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), e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
"subUUID")) "subUUID"))
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), 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(), uuid.NewV4().String(),
"Default list", "Default list",
models.ListTypePublic, models.ListTypePublic,
models.ListOptinSingle,
pq.StringArray{"test"}, pq.StringArray{"test"},
); err != nil { ); err != nil {
logger.Fatalf("Error creating superadmin user: %v", err) logger.Fatalf("Error creating list: %v", err)
} }
// Sample subscriber. // Sample subscriber.

View File

@ -92,6 +92,7 @@ func handleCreateList(c echo.Context) error {
o.UUID, o.UUID,
o.Name, o.Name,
o.Type, o.Type,
o.Optin,
pq.StringArray(normalizeTags(o.Tags))); err != nil { pq.StringArray(normalizeTags(o.Tags))); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating list: %s", pqErrMsg(err))) fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
@ -120,7 +121,7 @@ func handleUpdateList(c echo.Context) error {
return err 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 { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error updating list: %s", pqErrMsg(err))) fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))

56
main.go
View File

@ -30,12 +30,16 @@ import (
) )
type constants struct { type constants struct {
RootURL string `koanf:"root"` RootURL string `koanf:"root"`
LogoURL string `koanf:"logo_url"` LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"` FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"` UnsubscribeURL string
NotifyEmails []string `koanf:"notify_emails"` LinkTrackURL string
Privacy privacyOptions `koanf:"privacy"` ViewTrackURL string
OptinURL string
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
Privacy privacyOptions `koanf:"privacy"`
} }
type privacyOptions struct { type privacyOptions struct {
@ -286,8 +290,8 @@ func main() {
app.Queries = q app.Queries = q
// Initialize the bulk subscriber importer. // Initialize the bulk subscriber importer.
importNotifCB := func(subject string, data map[string]interface{}) error { importNotifCB := func(subject string, data interface{}) error {
go sendNotification(notifTplImport, subject, data, app) go sendNotification(app.Constants.NotifyEmails, subject, notifTplImport, data, app)
return nil return nil
} }
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt, app.Importer = subimporter.New(q.UpsertSubscriber.Stmt,
@ -296,30 +300,38 @@ func main() {
db.DB, db.DB,
importNotifCB) importNotifCB)
// Read system e-mail templates. // Prepare notification e-mail templates.
notifTpls, err := stuffbin.ParseTemplatesGlob(nil, fs, "/email-templates/*.html") notifTpls, err := compileNotifTpls("/email-templates/*.html", fs, app)
if err != nil { 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 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. // Initialize the campaign manager.
campNotifCB := func(subject string, data map[string]interface{}) error { campNotifCB := func(subject string, data interface{}) error {
return sendNotification(notifTplCampaign, subject, data, app) return sendNotification(app.Constants.NotifyEmails, subject, notifTplCampaign, data, app)
} }
m := manager.New(manager.Config{ m := manager.New(manager.Config{
Concurrency: ko.Int("app.concurrency"), Concurrency: ko.Int("app.concurrency"),
MaxSendErrors: ko.Int("app.max_send_errors"), MaxSendErrors: ko.Int("app.max_send_errors"),
FromEmail: app.Constants.FromEmail, FromEmail: app.Constants.FromEmail,
UnsubURL: c.UnsubscribeURL,
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid} OptinURL: c.OptinURL,
UnsubURL: fmt.Sprintf("%s/subscription/%%s/%%s", app.Constants.RootURL), LinkTrackURL: c.LinkTrackURL,
ViewTrackURL: c.ViewTrackURL,
// 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),
}, newManagerDB(q), campNotifCB, logger) }, newManagerDB(q), campNotifCB, logger)
app.Manager = m app.Manager = m

View File

@ -63,9 +63,8 @@ type Message struct {
Subscriber *models.Subscriber Subscriber *models.Subscriber
Body []byte Body []byte
unsubURL string from string
from string to string
to string
} }
// Config has parameters for configuring the manager. // Config has parameters for configuring the manager.
@ -76,6 +75,7 @@ type Config struct {
FromEmail string FromEmail string
LinkTrackURL string LinkTrackURL string
UnsubURL string UnsubURL string
OptinURL string
ViewTrackURL string ViewTrackURL string
} }
@ -108,9 +108,8 @@ func (m *Manager) NewMessage(c *models.Campaign, s *models.Subscriber) *Message
Campaign: c, Campaign: c,
Subscriber: s, Subscriber: s,
from: c.FromEmail, from: c.FromEmail,
to: s.Email, 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))) fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, msg.Subscriber.UUID)))
}, },
"UnsubscribeURL": func(msg *Message) string { "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 { "Date": func(layout string) string {
if layout == "" { if layout == "" {

View File

@ -23,6 +23,11 @@ const (
SubscriberStatusDisabled = "disabled" SubscriberStatusDisabled = "disabled"
SubscriberStatusBlackListed = "blacklisted" SubscriberStatusBlackListed = "blacklisted"
// Subscription.
SubscriptionStatusUnconfirmed = "unconfirmed"
SubscriptionStatusConfirmed = "confirmed"
SubscriptionStatusUnsubscribed = "unsubscribed"
// Campaign. // Campaign.
CampaignStatusDraft = "draft" CampaignStatusDraft = "draft"
CampaignStatusScheduled = "scheduled" CampaignStatusScheduled = "scheduled"
@ -34,6 +39,8 @@ const (
// List. // List.
ListTypePrivate = "private" ListTypePrivate = "private"
ListTypePublic = "public" ListTypePublic = "public"
ListOptinSingle = "single"
ListOptinDouble = "double"
// User. // User.
UserTypeSuperadmin = "superadmin" UserTypeSuperadmin = "superadmin"
@ -72,7 +79,7 @@ var regTplFuncs = []regTplFunc{
// AdminNotifCallback is a callback function that's called // AdminNotifCallback is a callback function that's called
// when a campaign's status changes. // 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. // Base holds common fields shared across models.
type Base struct { type Base struct {
@ -126,6 +133,7 @@ type List struct {
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
Optin string `db:"optin" json:"optin"`
Tags pq.StringArray `db:"tags" json:"tags"` Tags pq.StringArray `db:"tags" json:"tags"`
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"` SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
SubscriberID int `db:"subscriber_id" json:"-"` 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 // FirstName splits the name by spaces and returns the first chunk
// of the name that's greater than 2 characters in length, assuming // of the name that's greater than 2 characters in length, assuming
// that it is the subscriber's first name. // 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, " ") { for _, s := range strings.Split(s.Name, " ") {
if len(s) > 2 { if len(s) > 2 {
return s return s
@ -319,7 +327,7 @@ func (s *Subscriber) FirstName() string {
// LastName splits the name by spaces and returns the last chunk // LastName splits the name by spaces and returns the last chunk
// of the name that's greater than 2 characters in length, assuming // of the name that's greater than 2 characters in length, assuming
// that it is the subscriber's last name. // that it is the subscriber's last name.
func (s *Subscriber) LastName() string { func (s Subscriber) LastName() string {
chunks := strings.Split(s.Name, " ") chunks := strings.Split(s.Name, " ")
for i := len(chunks) - 1; i >= 0; i-- { for i := len(chunks) - 1; i >= 0; i-- {
chunk := chunks[i] chunk := chunks[i]

View File

@ -2,25 +2,35 @@ package main
import ( import (
"bytes" "bytes"
"html/template"
"github.com/knadh/stuffbin"
) )
const ( const (
notifTplImport = "import-status" notifTplImport = "import-status"
notifTplCampaign = "campaign-status" notifTplCampaign = "campaign-status"
notifSubscriberOptin = "subscriber-optin"
notifSubscriberData = "subscriber-data"
) )
// sendNotification sends out an e-mail notification to admins. // notifData represents params commonly used across different notification
func sendNotification(tpl, subject string, data map[string]interface{}, app *App) error { // templates.
data["RootURL"] = app.Constants.RootURL 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 var b bytes.Buffer
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data) if err := app.NotifTpls.ExecuteTemplate(&b, tplName, data); err != nil {
if err != nil { app.Logger.Printf("error compiling notification template '%s': %v", tplName, err)
return err return err
} }
err = app.Messenger.Push(app.Constants.FromEmail, err := app.Messenger.Push(app.Constants.FromEmail,
app.Constants.NotifyEmails, toEmails,
subject, subject,
b.Bytes(), b.Bytes(),
nil) 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) app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
return err return err
} }
return nil return nil
} }
func getNotificationTemplate(tpl string, data map[string]interface{}, app *App) ([]byte, error) { // compileNotifTpls compiles and returns e-mail notification templates that are
if data == nil { // used for sending ad-hoc notifications to admins and subscribers.
data = make(map[string]interface{}) func compileNotifTpls(path string, fs stuffbin.FileSystem, app *App) (*template.Template, error) {
} // Register utility functions that the e-mail templates can use.
data["RootURL"] = app.Constants.RootURL funcs := template.FuncMap{
"RootURL": func() string {
return app.Constants.RootURL
},
"LogoURL": func() string {
return app.Constants.LogoURL
}}
var b bytes.Buffer tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/email-templates/*.html")
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return b.Bytes(), err return tpl, err
} }

View File

@ -10,6 +10,7 @@ import (
"strconv" "strconv"
"github.com/knadh/listmonk/messenger" "github.com/knadh/listmonk/messenger"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo" "github.com/labstack/echo"
"github.com/lib/pq" "github.com/lib/pq"
) )
@ -44,6 +45,13 @@ type unsubTpl struct {
AllowWipe bool AllowWipe bool
} }
type optinTpl struct {
publicTpl
SubUUID string
ListUUIDs []string `query:"l" form:"l"`
Lists []models.List `query:"-" form:"-"`
}
type msgTpl struct { type msgTpl struct {
publicTpl publicTpl
MessageTitle string MessageTitle string
@ -102,6 +110,73 @@ func handleSubscriptionPage(c echo.Context) error {
return c.Render(http.StatusOK, "subscription", out) 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. // handleLinkRedirect handles link UUID to real link redirection.
func handleLinkRedirect(c echo.Context) error { func handleLinkRedirect(c echo.Context) error {
var ( var (
@ -166,9 +241,9 @@ func handleSelfExportSubscriberData(c echo.Context) error {
} }
// Send the data out to the subscriber as an atachment. // Send the data out to the subscriber as an atachment.
msg, err := getNotificationTemplate("subscriber-data", nil, app) var msg bytes.Buffer
if err != nil { if err := app.NotifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
app.Logger.Printf("error preparing subscriber data e-mail template: %s", err) app.Logger.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
return c.Render(http.StatusInternalServerError, "message", return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error preparing data", "", makeMsgTpl("Error preparing data", "",
"There was an error preparing your data. Please try later.")) "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, if err := app.Messenger.Push(app.Constants.FromEmail,
[]string{data.Email}, []string{data.Email},
"Your profile data", "Your profile data",
msg, msg.Bytes(),
[]*messenger.Attachment{ []*messenger.Attachment{
&messenger.Attachment{ &messenger.Attachment{
Name: fname, Name: fname,

View File

@ -175,6 +175,11 @@
} }
} /*# sourceMappingURL=dist/flexit.min.css.map */ } /*# sourceMappingURL=dist/flexit.min.css.map */
html, body {
padding: 0;
margin: 0;
min-width: 320px;
}
body { body {
background: #f9f9f9; background: #f9f9f9;
font-family: "Open Sans", "Helvetica Neue", sans-serif; font-family: "Open Sans", "Helvetica Neue", sans-serif;
@ -235,7 +240,9 @@ section {
} }
.header { .header {
margin-bottom: 60px; border-bottom: 1px solid #eee;
padding-bottom: 15px;
margin-bottom: 30px;
} }
.header .logo img { .header .logo img {
width: auto; width: auto;
@ -266,8 +273,6 @@ section {
@media screen and (max-width: 650px) { @media screen and (max-width: 650px) {
.wrap { .wrap {
margin: 0; margin: 0;
} padding: 30px;
.header {
margin-bottom: 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"` GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
SubscriberExists *sqlx.Stmt `query:"subscriber-exists"` SubscriberExists *sqlx.Stmt `query:"subscriber-exists"`
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"` UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"` BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"`
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"` AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"` DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"`
ConfirmSubscriptionOptin *sqlx.Stmt `query:"confirm-subscription-optin"`
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"` UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"` DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
Unsubscribe *sqlx.Stmt `query:"unsubscribe"` Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
@ -40,6 +42,8 @@ type Queries struct {
CreateList *sqlx.Stmt `query:"create-list"` CreateList *sqlx.Stmt `query:"create-list"`
GetLists *sqlx.Stmt `query:"get-lists"` 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"` UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
DeleteLists *sqlx.Stmt `query:"delete-lists"` 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); SELECT * FROM subscribers WHERE email=ANY($1);
-- name: get-subscriber-lists -- 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. -- Get lists associations of subscribers given a list of subscriber IDs.
-- This query is used to lazy load 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, -- 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 DELETE FROM subscriber_lists
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b); 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 -- name: unsubscribe-subscribers-from-lists
UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW() 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); 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) 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); 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 -- 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 -- name: update-list
UPDATE lists SET UPDATE lists SET
name=(CASE WHEN $2 != '' THEN $2 ELSE name END), name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
type=(CASE WHEN $3 != '' THEN $3::list_type ELSE type 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() updated_at=NOW()
WHERE id = $1; WHERE id = $1;
@ -296,10 +322,25 @@ DELETE FROM lists WHERE id = ALL($1);
-- campaigns -- campaigns
-- name: create-campaign -- name: create-campaign
-- This creates the campaign and inserts campaign_lists relationships. -- 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 SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
FROM subscribers 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[]) WHERE subscriber_lists.list_id=ANY($11::INT[])
AND subscribers.status='enabled' AND subscribers.status='enabled'
), ),
@ -398,17 +439,32 @@ WITH camps AS (
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW())) WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
AND NOT(campaigns.id = ANY($1::INT[])) AND NOT(campaigns.id = ANY($1::INT[]))
), ),
counts AS ( campLists AS (
-- For each campaign above, get the total number of subscribers and the max_subscriber_id across all its lists. -- Get the list_ids and their optin statuses for the campaigns found in the previous step.
SELECT id AS campaign_id, COUNT(subscriber_lists.subscriber_id) AS to_send, SELECT id AS list_id, campaign_id, optin FROM lists
COALESCE(MAX(subscriber_lists.subscriber_id), 0) AS max_subscriber_id FROM camps INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
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')
WHERE campaign_lists.campaign_id = ANY(SELECT id FROM camps) 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 GROUP BY camps.id
), ),
u AS ( 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 UPDATE campaigns AS ca
SET to_send = co.to_send, SET to_send = co.to_send,
status = (CASE WHEN status != 'running' THEN 'running' ELSE status END), 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 -- 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 -- (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. -- 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 SELECT last_subscriber_id, max_subscriber_id
FROM campaigns FROM campaigns
WHERE id=$1 AND status='running' 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 ( subs AS (
SELECT DISTINCT ON(id) id AS uniq_id, * FROM subscribers SELECT DISTINCT ON(subscribers.id) id AS uniq_id, subscribers.* FROM subscriber_lists
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id AND subscriber_lists.status != 'unsubscribed') INNER JOIN campLists ON (
WHERE subscriber_lists.list_id=ANY( campLists.list_id = subscriber_lists.list_id
SELECT list_id FROM campaign_lists where campaign_id=$1 AND list_id IS NOT NULL
) )
AND subscribers.status != 'blacklisted' INNER JOIN subscribers ON (
AND id > (SELECT last_subscriber_id FROM camp) subscribers.status != 'blacklisted' AND
AND id <= (SELECT max_subscriber_id FROM camp) 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 ORDER BY id LIMIT $2
), ),
u AS ( u AS (
UPDATE campaigns UPDATE campaigns
SET last_subscriber_id=(SELECT MAX(id) FROM subs), SET last_subscriber_id = (SELECT MAX(id) FROM subs),
sent=sent + (SELECT COUNT(id) FROM subs), sent = sent + (SELECT COUNT(id) FROM subs),
updated_at=NOW() updated_at = NOW()
WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1 WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1
) )
SELECT * FROM subs; SELECT * FROM subs;

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_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 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 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'); 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, uuid uuid NOT NULL UNIQUE,
name TEXT NOT NULL, name TEXT NOT NULL,
type list_type NOT NULL, type list_type NOT NULL,
optin list_optin NOT NULL DEFAULT 'single',
tags VARCHAR(100)[], tags VARCHAR(100)[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
@ -46,6 +47,14 @@ type subProfileData struct {
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"` 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{ var dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app", Email: "dummy@listmonk.app",
Name: "Dummy Subscriber", Name: "Dummy Subscriber",
@ -73,7 +82,7 @@ func handleGetSubscriber(c echo.Context) error {
if len(out) == 0 { if len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.") 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.") 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. // 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, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err))) fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
} }
@ -157,10 +166,14 @@ func handleCreateSubscriber(c echo.Context) error {
} }
// Insert and read ID. // 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, err := app.Queries.InsertSubscriber.Get(&newID,
uuid.NewV4(), req.UUID,
strings.ToLower(strings.TrimSpace(req.Email)), email,
strings.TrimSpace(req.Name), strings.TrimSpace(req.Name),
req.Status, req.Status,
req.Attribs, req.Attribs,
@ -169,11 +182,13 @@ func handleCreateSubscriber(c echo.Context) error {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { 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.StatusBadRequest, "The e-mail already exists.")
} }
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err)) 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. // Hand over to the GET handler to return the last insertion.
c.SetParamNames("id") c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID)) c.SetParamValues(fmt.Sprintf("%d", newID))
@ -503,6 +518,44 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool,
return data, b, nil 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 // sanitizeSQLExp does basic sanitisation on arbitrary
// SQL query expressions coming from the frontend. // SQL query expressions coming from the frontend.
func sanitizeSQLExp(q string) string { func sanitizeSQLExp(q string) string {