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.
This commit is contained in:
Kailash Nadh 2020-02-03 13:18:26 +05:30
parent 9a890c77ab
commit 022b35c4a7
11 changed files with 185 additions and 31 deletions

View file

@ -1,10 +1,13 @@
package main package main
import ( import (
"bytes"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -33,6 +36,8 @@ type campaignReq struct {
// This is only relevant to campaign test requests. // This is only relevant to campaign test requests.
SubscriberEmails pq.StringArray `json:"subscribers"` SubscriberEmails pq.StringArray `json:"subscribers"`
Type string `json:"type"`
} }
type campaignStats struct { type campaignStats struct {
@ -191,9 +196,20 @@ func handleCreateCampaign(c echo.Context) error {
return err 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. // Validate.
if err := validateCampaignFields(o, app); err != nil { if c, err := validateCampaignFields(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
o = c
} }
if !app.Manager.HasMessenger(o.MessengerID) { if !app.Manager.HasMessenger(o.MessengerID) {
@ -205,6 +221,7 @@ func handleCreateCampaign(c echo.Context) error {
var newID int var newID int
if err := app.Queries.CreateCampaign.Get(&newID, if err := app.Queries.CreateCampaign.Get(&newID,
uuid.NewV4(), uuid.NewV4(),
o.Type,
o.Name, o.Name,
o.Subject, o.Subject,
o.FromEmail, o.FromEmail,
@ -228,7 +245,6 @@ func handleCreateCampaign(c echo.Context) error {
// 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))
return handleGetCampaigns(c) return handleGetCampaigns(c)
} }
@ -265,8 +281,10 @@ func handleUpdateCampaign(c echo.Context) error {
return err return err
} }
if err := validateCampaignFields(o, app); err != nil { if c, err := validateCampaignFields(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
o = c
} }
res, err := app.Queries.UpdateCampaign.Exec(cm.ID, res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
@ -457,8 +475,10 @@ func handleTestCampaign(c echo.Context) error {
return err return err
} }
// Validate. // Validate.
if err := validateCampaignFields(req, app); err != nil { if c, err := validateCampaignFields(req, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
req = c
} }
if len(req.SubscriberEmails) == 0 { if len(req.SubscriberEmails) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.") 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. // validateCampaignFields validates incoming campaign field values.
func validateCampaignFields(c campaignReq, app *App) error { func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
if !regexFromAddress.Match([]byte(c.FromEmail)) { if c.FromEmail == "" {
c.FromEmail = app.Constants.FromEmail
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
if !govalidator.IsEmail(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) { 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) { 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) { // 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 there's a "send_at" date, it should be in the future.
if c.SendAt.Valid { if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) { 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} camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
if err := c.CompileTemplate(app.Manager.TemplateFuncs(&camp)); err != nil { 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 // 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.CampaignStatusCancelled ||
status == models.CampaignStatusFinished 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
}

View file

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

View file

@ -259,6 +259,7 @@ class TheFormDef extends React.PureComponent {
values.tags = [] values.tags = []
} }
values.type = cs.CampaignTypeRegular
values.body = this.props.body values.body = this.props.body
values.content_type = this.props.contentType values.content_type = this.props.contentType
@ -469,7 +470,8 @@ class TheFormDef extends React.PureComponent {
})( })(
<Select disabled={this.props.formDisabled} mode="multiple"> <Select disabled={this.props.formDisabled} mode="multiple">
{this.props.data[cs.ModelLists].hasOwnProperty("results") && {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"]}> <Select.Option value={v["id"]} key={v["id"]}>
{v["name"]} {v["name"]}
</Select.Option> </Select.Option>
@ -684,6 +686,11 @@ class Campaign extends React.PureComponent {
> >
{this.state.record.status} {this.state.record.status}
</Tag> </Tag>
{this.state.record.type === cs.CampaignStatusOptin && (
<Tag className="campaign-type" color="geekblue">
{this.state.record.type}
</Tag>
)}
{this.state.record.name} {this.state.record.name}
</h1> </h1>
<span className="text-tiny text-grey"> <span className="text-tiny text-grey">

View file

@ -104,7 +104,14 @@ class Campaigns extends React.PureComponent {
const out = [] const out = []
out.push( out.push(
<div className="name" key={`name-${record.id}`}> <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 /> <br />
<span className="text-tiny">{record.subject}</span> <span className="text-tiny">{record.subject}</span>
</div> </div>

View file

@ -269,7 +269,7 @@ class Lists extends React.PureComponent {
{record.optin === cs.ListOptinDouble && ( {record.optin === cs.ListOptinDouble && (
<p className="text-small"> <p className="text-small">
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in"> <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 <Icon type="rocket" /> Send opt-in campaign
</Link> </Link>
</Tooltip> </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 = () => { handleHideForm = () => {
this.setState({ formType: null }) this.setState({ formType: null })
} }

View file

@ -45,6 +45,12 @@ export const CampaignStatusRunning = "running"
export const CampaignStatusPaused = "paused" export const CampaignStatusPaused = "paused"
export const CampaignStatusFinished = "finished" export const CampaignStatusFinished = "finished"
export const CampaignStatusCancelled = "cancelled" 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 CampaignContentTypeRichtext = "richtext"
export const CampaignContentTypeHTML = "html" export const CampaignContentTypeHTML = "html"
export const CampaignContentTypePlain = "plain" export const CampaignContentTypePlain = "plain"

View file

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

View file

@ -138,19 +138,17 @@ func handleOptinPage(c echo.Context) error {
} }
// Get lists by UUIDs. // 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)) app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
return c.Render(http.StatusInternalServerError, "message", return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error", "", makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
`Error fetching lists. Please retry.`))
} }
} else { } else {
// Otherwise, get the list of all unconfirmed lists for the subscriber. // 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 { 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)) app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
return c.Render(http.StatusInternalServerError, "message", return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error", "", makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
`Error fetching lists. Please retry.`))
} }
} }

View file

@ -43,7 +43,6 @@ 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"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
GetListsByUUID *sqlx.Stmt `query:"get-lists-by-uuid"`
UpdateList *sqlx.Stmt `query:"update-list"` UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
DeleteLists *sqlx.Stmt `query:"delete-lists"` DeleteLists *sqlx.Stmt `query:"delete-lists"`

View file

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

View file

@ -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 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');
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'); DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain');
-- subscribers -- subscribers
@ -79,6 +80,10 @@ CREATE TABLE campaigns (
status campaign_status NOT NULL DEFAULT 'draft', status campaign_status NOT NULL DEFAULT 'draft',
tags VARCHAR(100)[], 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. -- The ID of the messenger backend used to send this campaign.
messenger TEXT NOT NULL, messenger TEXT NOT NULL,
template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1, template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1,