Browse Source

Add support for sending 'opt-in' campaigns.

- Campaigns now have a `type` property (regular, opt-in)
- Opt-in campaigns work for double opt-in lists and e-mail
  subscribers who haven't confirmed their subscriptions.
- Lists UI shows a 'Send opt-in campaign' optin that
  automatically creates an opt-in campaign for the list
  with a default message body that can be tweaked before
  sending the campaign.
- Primary usecase is to send opt-in campaigns to subscribers
  who are added via bulk import.

This is a breaking change. Adds a new Postgres enum type
`campaign_type` and a new column `type` to the campaigns table.
Kailash Nadh 5 years ago
parent
commit
022b35c4a7

+ 85 - 13
campaigns.go

@@ -1,10 +1,13 @@
 package main
 
 import (
+	"bytes"
 	"database/sql"
 	"errors"
 	"fmt"
+	"html/template"
 	"net/http"
+	"net/url"
 	"regexp"
 	"strconv"
 	"strings"
@@ -33,6 +36,8 @@ type campaignReq struct {
 
 	// This is only relevant to campaign test requests.
 	SubscriberEmails pq.StringArray `json:"subscribers"`
+
+	Type string `json:"type"`
 }
 
 type campaignStats struct {
@@ -191,9 +196,20 @@ func handleCreateCampaign(c echo.Context) error {
 		return err
 	}
 
+	// If the campaign's 'opt-in', prepare a default message.
+	if o.Type == models.CampaignTypeOptin {
+		op, err := makeOptinCampaignMessage(o, app)
+		if err != nil {
+			return err
+		}
+		o = op
+	}
+
 	// Validate.
-	if err := validateCampaignFields(o, app); err != nil {
+	if c, err := validateCampaignFields(o, app); err != nil {
 		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+	} else {
+		o = c
 	}
 
 	if !app.Manager.HasMessenger(o.MessengerID) {
@@ -205,6 +221,7 @@ func handleCreateCampaign(c echo.Context) error {
 	var newID int
 	if err := app.Queries.CreateCampaign.Get(&newID,
 		uuid.NewV4(),
+		o.Type,
 		o.Name,
 		o.Subject,
 		o.FromEmail,
@@ -228,7 +245,6 @@ func handleCreateCampaign(c echo.Context) error {
 	// Hand over to the GET handler to return the last insertion.
 	c.SetParamNames("id")
 	c.SetParamValues(fmt.Sprintf("%d", newID))
-
 	return handleGetCampaigns(c)
 }
 
@@ -265,8 +281,10 @@ func handleUpdateCampaign(c echo.Context) error {
 		return err
 	}
 
-	if err := validateCampaignFields(o, app); err != nil {
+	if c, err := validateCampaignFields(o, app); err != nil {
 		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+	} else {
+		o = c
 	}
 
 	res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
@@ -457,8 +475,10 @@ func handleTestCampaign(c echo.Context) error {
 		return err
 	}
 	// Validate.
-	if err := validateCampaignFields(req, app); err != nil {
+	if c, err := validateCampaignFields(req, app); err != nil {
 		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
+	} else {
+		req = c
 	}
 	if len(req.SubscriberEmails) == 0 {
 		return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
@@ -524,37 +544,39 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
 }
 
 // validateCampaignFields validates incoming campaign field values.
-func validateCampaignFields(c campaignReq, app *App) error {
-	if !regexFromAddress.Match([]byte(c.FromEmail)) {
+func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
+	if c.FromEmail == "" {
+		c.FromEmail = app.Constants.FromEmail
+	} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
 		if !govalidator.IsEmail(c.FromEmail) {
-			return errors.New("invalid `from_email`")
+			return c, errors.New("invalid `from_email`")
 		}
 	}
 
 	if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) {
-		return errors.New("invalid length for `name`")
+		return c, errors.New("invalid length for `name`")
 	}
 	if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) {
-		return errors.New("invalid length for `subject`")
+		return c, errors.New("invalid length for `subject`")
 	}
 
 	// if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) {
-	// 	return errors.New("invalid length for `body`")
+	// 	return c,errors.New("invalid length for `body`")
 	// }
 
 	// If there's a "send_at" date, it should be in the future.
 	if c.SendAt.Valid {
 		if c.SendAt.Time.Before(time.Now()) {
-			return errors.New("`send_at` date should be in the future")
+			return c, errors.New("`send_at` date should be in the future")
 		}
 	}
 
 	camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
 	if err := c.CompileTemplate(app.Manager.TemplateFuncs(&camp)); err != nil {
-		return fmt.Errorf("Error compiling campaign body: %v", err)
+		return c, fmt.Errorf("Error compiling campaign body: %v", err)
 	}
 
-	return nil
+	return c, nil
 }
 
 // isCampaignalMutable tells if a campaign's in a state where it's
@@ -564,3 +586,53 @@ func isCampaignalMutable(status string) bool {
 		status == models.CampaignStatusCancelled ||
 		status == models.CampaignStatusFinished
 }
+
+// makeOptinCampaignMessage makes a default opt-in campaign message body.
+func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
+	if len(o.ListIDs) == 0 {
+		return o, echo.NewHTTPError(http.StatusBadRequest, "Invalid list IDs.")
+	}
+
+	// Fetch double opt-in lists from the given list IDs.
+	var lists []models.List
+	err := app.Queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil)
+	if err != nil {
+		app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
+		return o, echo.NewHTTPError(http.StatusInternalServerError,
+			"Error fetching optin lists.")
+	}
+
+	// No opt-in lists.
+	if len(lists) == 0 {
+		return o, echo.NewHTTPError(http.StatusBadRequest,
+			"No opt-in lists found to create campaign.")
+	}
+
+	// Construct the opt-in URL with list IDs.
+	var (
+		listIDs   = url.Values{}
+		listNames = make([]string, 0, len(lists))
+	)
+	for _, l := range lists {
+		listIDs.Add("l", l.UUID)
+		listNames = append(listNames, l.Name)
+	}
+	// optinURLFunc := template.URL("{{ OptinURL }}?" + listIDs.Encode())
+	optinURLAttr := template.HTMLAttr(fmt.Sprintf(`href="{{ OptinURL }}%s"`, listIDs.Encode()))
+
+	// Prepare sample opt-in message for the campaign.
+	var b bytes.Buffer
+	if err := app.NotifTpls.ExecuteTemplate(&b, "optin-campaign", struct {
+		Lists        []models.List
+		OptinURLAttr template.HTMLAttr
+	}{lists, optinURLAttr}); err != nil {
+		app.Logger.Printf("error compiling 'optin-campaign' template: %v", err)
+		return o, echo.NewHTTPError(http.StatusInternalServerError,
+			"Error compiling opt-in campaign template.")
+	}
+
+	o.Name = "Opt-in campaign " + strings.Join(listNames, ", ")
+	o.Subject = "Confirm your subscription(s)"
+	o.Body = b.String()
+	return o, nil
+}

+ 17 - 0
email-templates/subscriber-optin-campaign.html

@@ -0,0 +1,17 @@
+{{ define "optin-campaign" }}
+
+<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>
+    <a class="button" {{ .OptinURLAttr }} class="button">Confirm subscription(s)</a>
+</p>
+{{ end }}

+ 13 - 6
frontend/src/Campaign.js

@@ -259,6 +259,7 @@ class TheFormDef extends React.PureComponent {
         values.tags = []
       }
 
+      values.type = cs.CampaignTypeRegular
       values.body = this.props.body
       values.content_type = this.props.contentType
 
@@ -398,14 +399,14 @@ class TheFormDef extends React.PureComponent {
             }
           });
         } else {
-        // eslint-disable-next-line radix
-        const id = parseInt(p.list_id)
-        if (id) {
-          subLists.push(id)
+          // eslint-disable-next-line radix
+          const id = parseInt(p.list_id)
+          if (id) {
+            subLists.push(id)
+          }
         }
       }
     }
-    }
 
     if (this.record) {
       this.props.pageTitle(record.name + " / Campaigns")
@@ -469,7 +470,8 @@ class TheFormDef extends React.PureComponent {
               })(
                 <Select disabled={this.props.formDisabled} mode="multiple">
                   {this.props.data[cs.ModelLists].hasOwnProperty("results") &&
-                    [...this.props.data[cs.ModelLists].results].map((v, i) => (
+                    [...this.props.data[cs.ModelLists].results].map((v) => 
+                      (record.type !== cs.CampaignTypeOptin || v.optin === cs.ListOptinDouble) && (
                       <Select.Option value={v["id"]} key={v["id"]}>
                         {v["name"]}
                       </Select.Option>
@@ -684,6 +686,11 @@ class Campaign extends React.PureComponent {
                   >
                     {this.state.record.status}
                   </Tag>
+                  {this.state.record.type === cs.CampaignStatusOptin && (
+                    <Tag className="campaign-type" color="geekblue">
+                      {this.state.record.type}
+                    </Tag>
+                  )}
                   {this.state.record.name}
                 </h1>
                 <span className="text-tiny text-grey">

+ 8 - 1
frontend/src/Campaigns.js

@@ -104,7 +104,14 @@ class Campaigns extends React.PureComponent {
           const out = []
           out.push(
             <div className="name" key={`name-${record.id}`}>
-              <Link to={`/campaigns/${record.id}`}>{text}</Link>
+              <Link to={`/campaigns/${record.id}`}>{text}</Link>{" "}
+              {record.type === cs.CampaignStatusOptin && (
+                <Tooltip title="Opt-in campaign" placement="top">
+                  <Tag className="campaign-type" color="geekblue">
+                    {record.type}
+                  </Tag>
+                </Tooltip>
+              )}
               <br />
               <span className="text-tiny">{record.subject}</span>
             </div>

+ 40 - 1
frontend/src/Lists.js

@@ -269,7 +269,7 @@ class Lists extends React.PureComponent {
               {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}`}>
+                    <Link onClick={ e => { e.preventDefault(); this.makeOptinCampaign(record)} } to={`/campaigns/new?type=optin&list_id=${record.id}`}>
                       <Icon type="rocket" /> Send opt-in campaign
                     </Link>
                   </Tooltip>
@@ -396,6 +396,45 @@ class Lists extends React.PureComponent {
       })
   }
 
+  makeOptinCampaign = record => {
+    this.props
+    .modelRequest(
+      cs.ModelCampaigns,
+      cs.Routes.CreateCampaign,
+      cs.MethodPost,
+      {
+        type: cs.CampaignTypeOptin,
+        name: "Optin: "+ record.name,
+        subject: "Confirm your subscriptions",
+        messenger: "email",
+        content_type: cs.CampaignContentTypeRichtext,
+        lists: [record.id]
+      }
+    )
+    .then(resp => {
+      notification["success"]({
+        placement: cs.MsgPosition,
+        message: "Opt-in campaign created",
+        description: "Opt-in campaign created"
+      })
+
+      // Redirect to the newly created campaign.
+      this.props.route.history.push({
+        pathname: cs.Routes.ViewCampaign.replace(
+          ":id",
+          resp.data.data.id
+        )
+      })
+    })
+    .catch(e => {
+      notification["error"]({
+        placement: cs.MsgPosition,
+        message: "Error",
+        description: e.message
+      })
+    })
+  }
+
   handleHideForm = () => {
     this.setState({ formType: null })
   }

+ 6 - 0
frontend/src/constants.js

@@ -45,6 +45,12 @@ export const CampaignStatusRunning = "running"
 export const CampaignStatusPaused = "paused"
 export const CampaignStatusFinished = "finished"
 export const CampaignStatusCancelled = "cancelled"
+export const CampaignStatusRegular = "regular"
+export const CampaignStatusOptin = "optin"
+
+export const CampaignTypeRegular = "regular"
+export const CampaignTypeOptin = "optin"
+
 export const CampaignContentTypeRichtext = "richtext"
 export const CampaignContentTypeHTML = "html"
 export const CampaignContentTypePlain = "plain"

+ 3 - 0
models/models.go

@@ -35,6 +35,8 @@ const (
 	CampaignStatusPaused    = "paused"
 	CampaignStatusFinished  = "finished"
 	CampaignStatusCancelled = "cancelled"
+	CampaignTypeRegular     = "regular"
+	CampaignTypeOptin       = "optin"
 
 	// List.
 	ListTypePrivate = "private"
@@ -152,6 +154,7 @@ type Campaign struct {
 	CampaignMeta
 
 	UUID        string         `db:"uuid" json:"uuid"`
+	Type        string         `db:"type" json:"type"`
 	Name        string         `db:"name" json:"name"`
 	Subject     string         `db:"subject" json:"subject"`
 	FromEmail   string         `db:"from_email" json:"from_email"`

+ 3 - 5
public.go

@@ -138,19 +138,17 @@ func handleOptinPage(c echo.Context) error {
 		}
 
 		// Get lists by UUIDs.
-		if err := app.Queries.GetListsByUUID.Select(&out.Lists, pq.StringArray(out.ListUUIDs)); err != nil {
+		if err := app.Queries.GetListsByOptin.Select(&out.Lists, models.ListOptinDouble, nil, 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.`))
+				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.`))
+				makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
 		}
 	}
 

+ 0 - 1
queries.go

@@ -43,7 +43,6 @@ 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"`

+ 5 - 4
queries.sql

@@ -295,10 +295,11 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
     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;
+-- Can have a list of IDs or a list of UUIDs.
+SELECT * FROM lists WHERE optin=$1::list_optin AND
+    (CASE WHEN $2::INT[] IS NOT NULL THEN id = ANY($2::INT[])
+          WHEN $3::UUID[] IS NOT NULL THEN uuid = ANY($3::UUID[])
+    END) ORDER BY name;
 
 -- name: create-list
 INSERT INTO lists (uuid, name, type, optin, tags) VALUES($1, $2, $3, $4, $5) RETURNING id;

+ 5 - 0
schema.sql

@@ -3,6 +3,7 @@ DROP TYPE IF EXISTS list_optin CASCADE; CREATE TYPE list_optin AS ENUM ('single'
 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');
+DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
 DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain');
 
 -- subscribers
@@ -79,6 +80,10 @@ CREATE TABLE campaigns (
     status           campaign_status NOT NULL DEFAULT 'draft',
     tags             VARCHAR(100)[],
 
+    -- The subscription statuses of subscribers to which a campaign will be sent.
+    -- For opt-in campaigns, this will be 'unsubscribed'.
+    type campaign_type DEFAULT 'regular',
+
     -- The ID of the messenger backend used to send this campaign. 
     messenger        TEXT NOT NULL,
     template_id      INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,