Browse Source

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.
Kailash Nadh 5 years ago
parent
commit
871893a9d2

+ 21 - 0
email-templates/subscriber-optin.html

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

+ 0 - 3
frontend/src/Import.js

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

+ 39 - 5
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 {
                 </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 (

+ 3 - 0
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",

+ 2 - 0
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),

+ 2 - 1
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.

+ 2 - 1
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)))

+ 34 - 22
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
 

+ 11 - 7
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 == "" {

+ 11 - 3
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]

+ 33 - 19
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
-
-	var b bytes.Buffer
-	err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
+// 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
+		}}
+
+	tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/email-templates/*.html")
 	if err != nil {
 		return nil, err
 	}
 
-	return b.Bytes(), err
+	return tpl, err
 }

+ 79 - 4
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,

+ 9 - 4
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;
   }
 }

+ 28 - 0
public/templates/optin.html

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

+ 4 - 0
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"`

+ 87 - 22
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;

+ 2 - 0
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(),

+ 59 - 6
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 {