Skip to content

Commit 022b35c

Browse files
committed
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.
1 parent 9a890c7 commit 022b35c

File tree

11 files changed

+185
-31
lines changed

11 files changed

+185
-31
lines changed

campaigns.go

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package main
22

33
import (
4+
"bytes"
45
"database/sql"
56
"errors"
67
"fmt"
8+
"html/template"
79
"net/http"
10+
"net/url"
811
"regexp"
912
"strconv"
1013
"strings"
@@ -33,6 +36,8 @@ type campaignReq struct {
3336

3437
// This is only relevant to campaign test requests.
3538
SubscriberEmails pq.StringArray `json:"subscribers"`
39+
40+
Type string `json:"type"`
3641
}
3742

3843
type campaignStats struct {
@@ -191,9 +196,20 @@ func handleCreateCampaign(c echo.Context) error {
191196
return err
192197
}
193198

199+
// If the campaign's 'opt-in', prepare a default message.
200+
if o.Type == models.CampaignTypeOptin {
201+
op, err := makeOptinCampaignMessage(o, app)
202+
if err != nil {
203+
return err
204+
}
205+
o = op
206+
}
207+
194208
// Validate.
195-
if err := validateCampaignFields(o, app); err != nil {
209+
if c, err := validateCampaignFields(o, app); err != nil {
196210
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
211+
} else {
212+
o = c
197213
}
198214

199215
if !app.Manager.HasMessenger(o.MessengerID) {
@@ -205,6 +221,7 @@ func handleCreateCampaign(c echo.Context) error {
205221
var newID int
206222
if err := app.Queries.CreateCampaign.Get(&newID,
207223
uuid.NewV4(),
224+
o.Type,
208225
o.Name,
209226
o.Subject,
210227
o.FromEmail,
@@ -228,7 +245,6 @@ func handleCreateCampaign(c echo.Context) error {
228245
// Hand over to the GET handler to return the last insertion.
229246
c.SetParamNames("id")
230247
c.SetParamValues(fmt.Sprintf("%d", newID))
231-
232248
return handleGetCampaigns(c)
233249
}
234250

@@ -265,8 +281,10 @@ func handleUpdateCampaign(c echo.Context) error {
265281
return err
266282
}
267283

268-
if err := validateCampaignFields(o, app); err != nil {
284+
if c, err := validateCampaignFields(o, app); err != nil {
269285
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
286+
} else {
287+
o = c
270288
}
271289

272290
res, err := app.Queries.UpdateCampaign.Exec(cm.ID,
@@ -457,8 +475,10 @@ func handleTestCampaign(c echo.Context) error {
457475
return err
458476
}
459477
// Validate.
460-
if err := validateCampaignFields(req, app); err != nil {
478+
if c, err := validateCampaignFields(req, app); err != nil {
461479
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
480+
} else {
481+
req = c
462482
}
463483
if len(req.SubscriberEmails) == 0 {
464484
return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
@@ -524,37 +544,39 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
524544
}
525545

526546
// validateCampaignFields validates incoming campaign field values.
527-
func validateCampaignFields(c campaignReq, app *App) error {
528-
if !regexFromAddress.Match([]byte(c.FromEmail)) {
547+
func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
548+
if c.FromEmail == "" {
549+
c.FromEmail = app.Constants.FromEmail
550+
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
529551
if !govalidator.IsEmail(c.FromEmail) {
530-
return errors.New("invalid `from_email`")
552+
return c, errors.New("invalid `from_email`")
531553
}
532554
}
533555

534556
if !govalidator.IsByteLength(c.Name, 1, stdInputMaxLen) {
535-
return errors.New("invalid length for `name`")
557+
return c, errors.New("invalid length for `name`")
536558
}
537559
if !govalidator.IsByteLength(c.Subject, 1, stdInputMaxLen) {
538-
return errors.New("invalid length for `subject`")
560+
return c, errors.New("invalid length for `subject`")
539561
}
540562

541563
// if !govalidator.IsByteLength(c.Body, 1, bodyMaxLen) {
542-
// return errors.New("invalid length for `body`")
564+
// return c,errors.New("invalid length for `body`")
543565
// }
544566

545567
// If there's a "send_at" date, it should be in the future.
546568
if c.SendAt.Valid {
547569
if c.SendAt.Time.Before(time.Now()) {
548-
return errors.New("`send_at` date should be in the future")
570+
return c, errors.New("`send_at` date should be in the future")
549571
}
550572
}
551573

552574
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
553575
if err := c.CompileTemplate(app.Manager.TemplateFuncs(&camp)); err != nil {
554-
return fmt.Errorf("Error compiling campaign body: %v", err)
576+
return c, fmt.Errorf("Error compiling campaign body: %v", err)
555577
}
556578

557-
return nil
579+
return c, nil
558580
}
559581

560582
// isCampaignalMutable tells if a campaign's in a state where it's
@@ -564,3 +586,53 @@ func isCampaignalMutable(status string) bool {
564586
status == models.CampaignStatusCancelled ||
565587
status == models.CampaignStatusFinished
566588
}
589+
590+
// makeOptinCampaignMessage makes a default opt-in campaign message body.
591+
func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
592+
if len(o.ListIDs) == 0 {
593+
return o, echo.NewHTTPError(http.StatusBadRequest, "Invalid list IDs.")
594+
}
595+
596+
// Fetch double opt-in lists from the given list IDs.
597+
var lists []models.List
598+
err := app.Queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil)
599+
if err != nil {
600+
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
601+
return o, echo.NewHTTPError(http.StatusInternalServerError,
602+
"Error fetching optin lists.")
603+
}
604+
605+
// No opt-in lists.
606+
if len(lists) == 0 {
607+
return o, echo.NewHTTPError(http.StatusBadRequest,
608+
"No opt-in lists found to create campaign.")
609+
}
610+
611+
// Construct the opt-in URL with list IDs.
612+
var (
613+
listIDs = url.Values{}
614+
listNames = make([]string, 0, len(lists))
615+
)
616+
for _, l := range lists {
617+
listIDs.Add("l", l.UUID)
618+
listNames = append(listNames, l.Name)
619+
}
620+
// optinURLFunc := template.URL("{{ OptinURL }}?" + listIDs.Encode())
621+
optinURLAttr := template.HTMLAttr(fmt.Sprintf(`href="{{ OptinURL }}%s"`, listIDs.Encode()))
622+
623+
// Prepare sample opt-in message for the campaign.
624+
var b bytes.Buffer
625+
if err := app.NotifTpls.ExecuteTemplate(&b, "optin-campaign", struct {
626+
Lists []models.List
627+
OptinURLAttr template.HTMLAttr
628+
}{lists, optinURLAttr}); err != nil {
629+
app.Logger.Printf("error compiling 'optin-campaign' template: %v", err)
630+
return o, echo.NewHTTPError(http.StatusInternalServerError,
631+
"Error compiling opt-in campaign template.")
632+
}
633+
634+
o.Name = "Opt-in campaign " + strings.Join(listNames, ", ")
635+
o.Subject = "Confirm your subscription(s)"
636+
o.Body = b.String()
637+
return o, nil
638+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{{ define "optin-campaign" }}
2+
3+
<p>Hi {{`{{ .Subscriber.FirstName }}`}},</p>
4+
<p>You have been added to the following mailing lists:</p>
5+
<ul>
6+
{{ range $i, $l := .Lists }}
7+
{{ if eq .Type "public" }}
8+
<li>{{ .Name }}</li>
9+
{{ else }}
10+
<li>Private list</li>
11+
{{ end }}
12+
{{ end }}
13+
</ul>
14+
<p>
15+
<a class="button" {{ .OptinURLAttr }} class="button">Confirm subscription(s)</a>
16+
</p>
17+
{{ end }}

frontend/src/Campaign.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ class TheFormDef extends React.PureComponent {
259259
values.tags = []
260260
}
261261

262+
values.type = cs.CampaignTypeRegular
262263
values.body = this.props.body
263264
values.content_type = this.props.contentType
264265

@@ -398,14 +399,14 @@ class TheFormDef extends React.PureComponent {
398399
}
399400
});
400401
} else {
401-
// eslint-disable-next-line radix
402-
const id = parseInt(p.list_id)
403-
if (id) {
404-
subLists.push(id)
402+
// eslint-disable-next-line radix
403+
const id = parseInt(p.list_id)
404+
if (id) {
405+
subLists.push(id)
406+
}
405407
}
406408
}
407409
}
408-
}
409410

410411
if (this.record) {
411412
this.props.pageTitle(record.name + " / Campaigns")
@@ -469,7 +470,8 @@ class TheFormDef extends React.PureComponent {
469470
})(
470471
<Select disabled={this.props.formDisabled} mode="multiple">
471472
{this.props.data[cs.ModelLists].hasOwnProperty("results") &&
472-
[...this.props.data[cs.ModelLists].results].map((v, i) => (
473+
[...this.props.data[cs.ModelLists].results].map((v) =>
474+
(record.type !== cs.CampaignTypeOptin || v.optin === cs.ListOptinDouble) && (
473475
<Select.Option value={v["id"]} key={v["id"]}>
474476
{v["name"]}
475477
</Select.Option>
@@ -684,6 +686,11 @@ class Campaign extends React.PureComponent {
684686
>
685687
{this.state.record.status}
686688
</Tag>
689+
{this.state.record.type === cs.CampaignStatusOptin && (
690+
<Tag className="campaign-type" color="geekblue">
691+
{this.state.record.type}
692+
</Tag>
693+
)}
687694
{this.state.record.name}
688695
</h1>
689696
<span className="text-tiny text-grey">

frontend/src/Campaigns.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,14 @@ class Campaigns extends React.PureComponent {
104104
const out = []
105105
out.push(
106106
<div className="name" key={`name-${record.id}`}>
107-
<Link to={`/campaigns/${record.id}`}>{text}</Link>
107+
<Link to={`/campaigns/${record.id}`}>{text}</Link>{" "}
108+
{record.type === cs.CampaignStatusOptin && (
109+
<Tooltip title="Opt-in campaign" placement="top">
110+
<Tag className="campaign-type" color="geekblue">
111+
{record.type}
112+
</Tag>
113+
</Tooltip>
114+
)}
108115
<br />
109116
<span className="text-tiny">{record.subject}</span>
110117
</div>

frontend/src/Lists.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ class Lists extends React.PureComponent {
269269
{record.optin === cs.ListOptinDouble && (
270270
<p className="text-small">
271271
<Tooltip title="Send a campaign to unconfirmed subscribers to opt-in">
272-
<Link to={`/campaigns/new?list_id=${record.id}`}>
272+
<Link onClick={ e => { e.preventDefault(); this.makeOptinCampaign(record)} } to={`/campaigns/new?type=optin&list_id=${record.id}`}>
273273
<Icon type="rocket" /> Send opt-in campaign
274274
</Link>
275275
</Tooltip>
@@ -396,6 +396,45 @@ class Lists extends React.PureComponent {
396396
})
397397
}
398398

399+
makeOptinCampaign = record => {
400+
this.props
401+
.modelRequest(
402+
cs.ModelCampaigns,
403+
cs.Routes.CreateCampaign,
404+
cs.MethodPost,
405+
{
406+
type: cs.CampaignTypeOptin,
407+
name: "Optin: "+ record.name,
408+
subject: "Confirm your subscriptions",
409+
messenger: "email",
410+
content_type: cs.CampaignContentTypeRichtext,
411+
lists: [record.id]
412+
}
413+
)
414+
.then(resp => {
415+
notification["success"]({
416+
placement: cs.MsgPosition,
417+
message: "Opt-in campaign created",
418+
description: "Opt-in campaign created"
419+
})
420+
421+
// Redirect to the newly created campaign.
422+
this.props.route.history.push({
423+
pathname: cs.Routes.ViewCampaign.replace(
424+
":id",
425+
resp.data.data.id
426+
)
427+
})
428+
})
429+
.catch(e => {
430+
notification["error"]({
431+
placement: cs.MsgPosition,
432+
message: "Error",
433+
description: e.message
434+
})
435+
})
436+
}
437+
399438
handleHideForm = () => {
400439
this.setState({ formType: null })
401440
}

frontend/src/constants.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export const CampaignStatusRunning = "running"
4545
export const CampaignStatusPaused = "paused"
4646
export const CampaignStatusFinished = "finished"
4747
export const CampaignStatusCancelled = "cancelled"
48+
export const CampaignStatusRegular = "regular"
49+
export const CampaignStatusOptin = "optin"
50+
51+
export const CampaignTypeRegular = "regular"
52+
export const CampaignTypeOptin = "optin"
53+
4854
export const CampaignContentTypeRichtext = "richtext"
4955
export const CampaignContentTypeHTML = "html"
5056
export const CampaignContentTypePlain = "plain"

models/models.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const (
3535
CampaignStatusPaused = "paused"
3636
CampaignStatusFinished = "finished"
3737
CampaignStatusCancelled = "cancelled"
38+
CampaignTypeRegular = "regular"
39+
CampaignTypeOptin = "optin"
3840

3941
// List.
4042
ListTypePrivate = "private"
@@ -152,6 +154,7 @@ type Campaign struct {
152154
CampaignMeta
153155

154156
UUID string `db:"uuid" json:"uuid"`
157+
Type string `db:"type" json:"type"`
155158
Name string `db:"name" json:"name"`
156159
Subject string `db:"subject" json:"subject"`
157160
FromEmail string `db:"from_email" json:"from_email"`

public.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,17 @@ func handleOptinPage(c echo.Context) error {
138138
}
139139

140140
// Get lists by UUIDs.
141-
if err := app.Queries.GetListsByUUID.Select(&out.Lists, pq.StringArray(out.ListUUIDs)); err != nil {
141+
if err := app.Queries.GetListsByOptin.Select(&out.Lists, models.ListOptinDouble, nil, pq.StringArray(out.ListUUIDs)); err != nil {
142142
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
143143
return c.Render(http.StatusInternalServerError, "message",
144-
makeMsgTpl("Error", "",
145-
`Error fetching lists. Please retry.`))
144+
makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
146145
}
147146
} else {
148147
// Otherwise, get the list of all unconfirmed lists for the subscriber.
149148
if err := app.Queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, models.SubscriptionStatusUnconfirmed); err != nil {
150149
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
151150
return c.Render(http.StatusInternalServerError, "message",
152-
makeMsgTpl("Error", "",
153-
`Error fetching lists. Please retry.`))
151+
makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
154152
}
155153
}
156154

queries.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ type Queries struct {
4343
CreateList *sqlx.Stmt `query:"create-list"`
4444
GetLists *sqlx.Stmt `query:"get-lists"`
4545
GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
46-
GetListsByUUID *sqlx.Stmt `query:"get-lists-by-uuid"`
4746
UpdateList *sqlx.Stmt `query:"update-list"`
4847
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`
4948
DeleteLists *sqlx.Stmt `query:"delete-lists"`

0 commit comments

Comments
 (0)