Selaa lähdekoodia

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 vuotta sitten
vanhempi
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">
             <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>

+ 39 - 5
frontend/src/Lists.js

@@ -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%",
-        render: (type, _) => {
+        width: "15%",
+        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 (

+ 3 - 0
frontend/src/constants.js

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

+ 2 - 0
handlers.go

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

+ 2 - 1
install.go

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

+ 2 - 1
lists.go

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

+ 34 - 22
main.go

@@ -30,12 +30,16 @@ import (
 )
 )
 
 
 type constants struct {
 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 {
 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 {
-		go sendNotification(notifTplImport, subject, data, app)
+	importNotifCB := func(subject string, data interface{}) error {
+		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.
-	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 {
 	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 {
-		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{
 	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,
-
-		// 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)
 	}, newManagerDB(q), campNotifCB, logger)
 	app.Manager = m
 	app.Manager = m
 
 

+ 11 - 7
manager/manager.go

@@ -63,9 +63,8 @@ type Message struct {
 	Subscriber *models.Subscriber
 	Subscriber *models.Subscriber
 	Body       []byte
 	Body       []byte
 
 
-	unsubURL string
-	from     string
-	to       string
+	from 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,
-		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)))
 				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 == "" {

+ 11 - 3
models/models.go

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

+ 33 - 19
notifications.go

@@ -2,25 +2,35 @@ package main
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"html/template"
+
+	"github.com/knadh/stuffbin"
 )
 )
 
 
 const (
 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
 	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
 		return err
 	}
 	}
 
 
-	err = app.Messenger.Push(app.Constants.FromEmail,
-		app.Constants.NotifyEmails,
+	err := app.Messenger.Push(app.Constants.FromEmail,
+		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) {
-	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 {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	return b.Bytes(), err
+	return tpl, err
 }
 }

+ 79 - 4
public.go

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

+ 9 - 4
public/static/style.css

@@ -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;
-  }
-  .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"`
 	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"`

+ 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);
 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 (
-    -- 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)
     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
-    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
     ORDER BY id LIMIT $2
 ),
 ),
 u AS (
 u AS (
     UPDATE campaigns
     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
     WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1
 )
 )
 SELECT * FROM subs;
 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_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(),

+ 59 - 6
subscribers.go

@@ -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(),
-		strings.ToLower(strings.TrimSpace(req.Email)),
+		req.UUID,
+		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 {