Skip to content

Commit

Permalink
Add double opt-in support.
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
knadh committed Feb 9, 2020
1 parent bdd42b6 commit 871893a
Show file tree
Hide file tree
Showing 18 changed files with 426 additions and 97 deletions.
21 changes: 21 additions & 0 deletions email-templates/subscriber-optin.html
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 0 additions & 3 deletions frontend/src/Import.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,19 +446,16 @@ class Import extends React.PureComponent {
<code className="csv-headers">
<span>email,</span>
<span>name,</span>
<span>status,</span>
<span>attributes</span>
</code>
<code className="csv-row">
<span>[email protected],</span>
<span>"User One",</span>
<span>enabled,</span>
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
</code>
<code className="csv-row">
<span>[email protected],</span>
<span>"User Two",</span>
<span>blacklisted,</span>
<span>
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
</span>
Expand Down
44 changes: 39 additions & 5 deletions frontend/src/Lists.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ class CreateFormDef extends React.PureComponent {
{...formItemLayout}
name="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", {
initialValue: record.type ? record.type : "private",
Expand All @@ -165,6 +166,23 @@ class CreateFormDef extends React.PureComponent {
</Select>
)}
</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
{...formItemLayout}
label="Tags"
Expand Down Expand Up @@ -239,16 +257,32 @@ class Lists extends React.PureComponent {
{
title: "Type",
dataIndex: "type",
width: "10%",
render: (type, _) => {
width: "15%",
render: (type, record) => {
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",
dataIndex: "subscriber_count",
width: "15%",
width: "10%",
align: "center",
render: (text, record) => {
return (
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const SubscriptionStatusConfirmed = "confirmed"
export const SubscriptionStatusUnConfirmed = "unconfirmed"
export const SubscriptionStatusUnsubscribed = "unsubscribed"

export const ListOptinSingle = "single"
export const ListOptinDouble = "double"

// API routes.
export const Routes = {
GetDashboarcStats: "/api/dashboard/stats",
Expand Down
2 changes: 2 additions & 0 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ func registerHandlers(e *echo.Echo) {
"campUUID", "subUUID"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"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),
"subUUID"))
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
Expand Down
3 changes: 2 additions & 1 deletion install.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ func install(app *App, qMap goyesql.Queries, prompt bool) {
uuid.NewV4().String(),
"Default list",
models.ListTypePublic,
models.ListOptinSingle,
pq.StringArray{"test"},
); err != nil {
logger.Fatalf("Error creating superadmin user: %v", err)
logger.Fatalf("Error creating list: %v", err)
}

// Sample subscriber.
Expand Down
3 changes: 2 additions & 1 deletion lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func handleCreateList(c echo.Context) error {
o.UUID,
o.Name,
o.Type,
o.Optin,
pq.StringArray(normalizeTags(o.Tags))); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
Expand Down Expand Up @@ -120,7 +121,7 @@ func handleUpdateList(c echo.Context) error {
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 {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))
Expand Down
56 changes: 34 additions & 22 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ import (
)

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 {
Expand Down Expand Up @@ -286,8 +290,8 @@ func main() {
app.Queries = q

// 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
}
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt,
Expand All @@ -296,30 +300,38 @@ func main() {
db.DB,
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 {
logger.Fatalf("error loading system e-mail templates: %v", err)
logger.Fatalf("error loading e-mail notification templates: %v", err)
}
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.
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{
Concurrency: ko.Int("app.concurrency"),
MaxSendErrors: ko.Int("app.max_send_errors"),
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)
app.Manager = m

Expand Down
18 changes: 11 additions & 7 deletions manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,8 @@ type Message struct {
Subscriber *models.Subscriber
Body []byte

unsubURL string
from string
to string
from string
to string
}

// Config has parameters for configuring the manager.
Expand All @@ -76,6 +75,7 @@ type Config struct {
FromEmail string
LinkTrackURL string
UnsubURL string
OptinURL string
ViewTrackURL string
}

Expand Down Expand Up @@ -108,9 +108,8 @@ func (m *Manager) NewMessage(c *models.Campaign, s *models.Subscriber) *Message
Campaign: c,
Subscriber: s,

from: c.FromEmail,
to: s.Email,
unsubURL: fmt.Sprintf(m.cfg.UnsubURL, c.UUID, s.UUID),
from: c.FromEmail,
to: s.Email,
}
}

Expand Down Expand Up @@ -423,7 +422,12 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, msg.Subscriber.UUID)))
},
"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 {
if layout == "" {
Expand Down
14 changes: 11 additions & 3 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const (
SubscriberStatusDisabled = "disabled"
SubscriberStatusBlackListed = "blacklisted"

// Subscription.
SubscriptionStatusUnconfirmed = "unconfirmed"
SubscriptionStatusConfirmed = "confirmed"
SubscriptionStatusUnsubscribed = "unsubscribed"

// Campaign.
CampaignStatusDraft = "draft"
CampaignStatusScheduled = "scheduled"
Expand All @@ -34,6 +39,8 @@ const (
// List.
ListTypePrivate = "private"
ListTypePublic = "public"
ListOptinSingle = "single"
ListOptinDouble = "double"

// User.
UserTypeSuperadmin = "superadmin"
Expand Down Expand Up @@ -72,7 +79,7 @@ var regTplFuncs = []regTplFunc{

// AdminNotifCallback is a callback function that's called
// 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.
type Base struct {
Expand Down Expand Up @@ -126,6 +133,7 @@ type List struct {
UUID string `db:"uuid" json:"uuid"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
Optin string `db:"optin" json:"optin"`
Tags pq.StringArray `db:"tags" json:"tags"`
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
SubscriberID int `db:"subscriber_id" json:"-"`
Expand Down Expand Up @@ -306,7 +314,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
// FirstName splits the name by spaces and returns the first chunk
// of the name that's greater than 2 characters in length, assuming
// 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, " ") {
if len(s) > 2 {
return s
Expand All @@ -319,7 +327,7 @@ func (s *Subscriber) FirstName() string {
// LastName splits the name by spaces and returns the last chunk
// of the name that's greater than 2 characters in length, assuming
// that it is the subscriber's last name.
func (s *Subscriber) LastName() string {
func (s Subscriber) LastName() string {
chunks := strings.Split(s.Name, " ")
for i := len(chunks) - 1; i >= 0; i-- {
chunk := chunks[i]
Expand Down
Loading

0 comments on commit 871893a

Please sign in to comment.