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:
parent
9a890c77ab
commit
022b35c4a7
11 changed files with 185 additions and 31 deletions
98
campaigns.go
98
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
email-templates/subscriber-optin-campaign.html
Normal file
17
email-templates/subscriber-optin-campaign.html
Normal 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 }}
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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.`))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue