Add double opt-in support.
- 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.
This commit is contained in:
parent
bdd42b66c5
commit
871893a9d2
|
@ -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 }}
|
|
@ -446,19 +446,16 @@ class Import extends React.PureComponent {
|
||||||
<code className="csv-headers">
|
<code className="csv-headers">
|
||||||
<span>email,</span>
|
<span>email,</span>
|
||||||
<span>name,</span>
|
<span>name,</span>
|
||||||
<span>status,</span>
|
|
||||||
<span>attributes</span>
|
<span>attributes</span>
|
||||||
</code>
|
</code>
|
||||||
<code className="csv-row">
|
<code className="csv-row">
|
||||||
<span>user1@mail.com,</span>
|
<span>user1@mail.com,</span>
|
||||||
<span>"User One",</span>
|
<span>"User One",</span>
|
||||||
<span>enabled,</span>
|
|
||||||
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
|
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
|
||||||
</code>
|
</code>
|
||||||
<code className="csv-row">
|
<code className="csv-row">
|
||||||
<span>user2@mail.com,</span>
|
<span>user2@mail.com,</span>
|
||||||
<span>"User Two",</span>
|
<span>"User Two",</span>
|
||||||
<span>blacklisted,</span>
|
|
||||||
<span>
|
<span>
|
||||||
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
|
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -153,7 +153,8 @@ class CreateFormDef extends React.PureComponent {
|
||||||
{...formItemLayout}
|
{...formItemLayout}
|
||||||
name="type"
|
name="type"
|
||||||
label="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", {
|
{getFieldDecorator("type", {
|
||||||
initialValue: record.type ? record.type : "private",
|
initialValue: record.type ? record.type : "private",
|
||||||
|
@ -165,6 +166,23 @@ class CreateFormDef extends React.PureComponent {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
{...formItemLayout}
|
{...formItemLayout}
|
||||||
label="Tags"
|
label="Tags"
|
||||||
|
@ -239,16 +257,32 @@ class Lists extends React.PureComponent {
|
||||||
{
|
{
|
||||||
title: "Type",
|
title: "Type",
|
||||||
dataIndex: "type",
|
dataIndex: "type",
|
||||||
width: "10%",
|
width: "15%",
|
||||||
render: (type, _) => {
|
render: (type, record) => {
|
||||||
let color = type === "private" ? "orange" : "green"
|
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",
|
title: "Subscribers",
|
||||||
dataIndex: "subscriber_count",
|
dataIndex: "subscriber_count",
|
||||||
width: "15%",
|
width: "10%",
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (text, record) => {
|
render: (text, record) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -50,6 +50,9 @@ export const SubscriptionStatusConfirmed = "confirmed"
|
||||||
export const SubscriptionStatusUnConfirmed = "unconfirmed"
|
export const SubscriptionStatusUnConfirmed = "unconfirmed"
|
||||||
export const SubscriptionStatusUnsubscribed = "unsubscribed"
|
export const SubscriptionStatusUnsubscribed = "unsubscribed"
|
||||||
|
|
||||||
|
export const ListOptinSingle = "single"
|
||||||
|
export const ListOptinDouble = "double"
|
||||||
|
|
||||||
// API routes.
|
// API routes.
|
||||||
export const Routes = {
|
export const Routes = {
|
||||||
GetDashboarcStats: "/api/dashboard/stats",
|
GetDashboarcStats: "/api/dashboard/stats",
|
||||||
|
|
|
@ -98,6 +98,8 @@ func registerHandlers(e *echo.Echo) {
|
||||||
"campUUID", "subUUID"))
|
"campUUID", "subUUID"))
|
||||||
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
||||||
"campUUID", "subUUID"))
|
"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),
|
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
|
||||||
"subUUID"))
|
"subUUID"))
|
||||||
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
|
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
|
||||||
|
|
|
@ -54,9 +54,10 @@ func install(app *App, qMap goyesql.Queries, prompt bool) {
|
||||||
uuid.NewV4().String(),
|
uuid.NewV4().String(),
|
||||||
"Default list",
|
"Default list",
|
||||||
models.ListTypePublic,
|
models.ListTypePublic,
|
||||||
|
models.ListOptinSingle,
|
||||||
pq.StringArray{"test"},
|
pq.StringArray{"test"},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Fatalf("Error creating superadmin user: %v", err)
|
logger.Fatalf("Error creating list: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample subscriber.
|
// Sample subscriber.
|
||||||
|
|
3
lists.go
3
lists.go
|
@ -92,6 +92,7 @@ func handleCreateList(c echo.Context) error {
|
||||||
o.UUID,
|
o.UUID,
|
||||||
o.Name,
|
o.Name,
|
||||||
o.Type,
|
o.Type,
|
||||||
|
o.Optin,
|
||||||
pq.StringArray(normalizeTags(o.Tags))); err != nil {
|
pq.StringArray(normalizeTags(o.Tags))); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
|
fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
|
||||||
|
@ -120,7 +121,7 @@ func handleUpdateList(c echo.Context) error {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest,
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))
|
fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))
|
||||||
|
|
56
main.go
56
main.go
|
@ -30,12 +30,16 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type constants struct {
|
type constants struct {
|
||||||
RootURL string `koanf:"root"`
|
RootURL string `koanf:"root"`
|
||||||
LogoURL string `koanf:"logo_url"`
|
LogoURL string `koanf:"logo_url"`
|
||||||
FaviconURL string `koanf:"favicon_url"`
|
FaviconURL string `koanf:"favicon_url"`
|
||||||
FromEmail string `koanf:"from_email"`
|
UnsubscribeURL string
|
||||||
NotifyEmails []string `koanf:"notify_emails"`
|
LinkTrackURL string
|
||||||
Privacy privacyOptions `koanf:"privacy"`
|
ViewTrackURL string
|
||||||
|
OptinURL string
|
||||||
|
FromEmail string `koanf:"from_email"`
|
||||||
|
NotifyEmails []string `koanf:"notify_emails"`
|
||||||
|
Privacy privacyOptions `koanf:"privacy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type privacyOptions struct {
|
type privacyOptions struct {
|
||||||
|
@ -286,8 +290,8 @@ func main() {
|
||||||
app.Queries = q
|
app.Queries = q
|
||||||
|
|
||||||
// Initialize the bulk subscriber importer.
|
// Initialize the bulk subscriber importer.
|
||||||
importNotifCB := func(subject string, data map[string]interface{}) error {
|
importNotifCB := func(subject string, data interface{}) error {
|
||||||
go sendNotification(notifTplImport, subject, data, app)
|
go sendNotification(app.Constants.NotifyEmails, subject, notifTplImport, data, app)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt,
|
app.Importer = subimporter.New(q.UpsertSubscriber.Stmt,
|
||||||
|
@ -296,30 +300,38 @@ func main() {
|
||||||
db.DB,
|
db.DB,
|
||||||
importNotifCB)
|
importNotifCB)
|
||||||
|
|
||||||
// Read system e-mail templates.
|
// Prepare notification e-mail templates.
|
||||||
notifTpls, err := stuffbin.ParseTemplatesGlob(nil, fs, "/email-templates/*.html")
|
notifTpls, err := compileNotifTpls("/email-templates/*.html", fs, app)
|
||||||
if err != nil {
|
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
|
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.
|
// Initialize the campaign manager.
|
||||||
campNotifCB := func(subject string, data map[string]interface{}) error {
|
campNotifCB := func(subject string, data interface{}) error {
|
||||||
return sendNotification(notifTplCampaign, subject, data, app)
|
return sendNotification(app.Constants.NotifyEmails, subject, notifTplCampaign, data, app)
|
||||||
}
|
}
|
||||||
m := manager.New(manager.Config{
|
m := manager.New(manager.Config{
|
||||||
Concurrency: ko.Int("app.concurrency"),
|
Concurrency: ko.Int("app.concurrency"),
|
||||||
MaxSendErrors: ko.Int("app.max_send_errors"),
|
MaxSendErrors: ko.Int("app.max_send_errors"),
|
||||||
FromEmail: app.Constants.FromEmail,
|
FromEmail: app.Constants.FromEmail,
|
||||||
|
UnsubURL: c.UnsubscribeURL,
|
||||||
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
|
OptinURL: c.OptinURL,
|
||||||
UnsubURL: fmt.Sprintf("%s/subscription/%%s/%%s", app.Constants.RootURL),
|
LinkTrackURL: c.LinkTrackURL,
|
||||||
|
ViewTrackURL: c.ViewTrackURL,
|
||||||
// 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),
|
|
||||||
}, newManagerDB(q), campNotifCB, logger)
|
}, newManagerDB(q), campNotifCB, logger)
|
||||||
app.Manager = m
|
app.Manager = m
|
||||||
|
|
||||||
|
|
|
@ -63,9 +63,8 @@ type Message struct {
|
||||||
Subscriber *models.Subscriber
|
Subscriber *models.Subscriber
|
||||||
Body []byte
|
Body []byte
|
||||||
|
|
||||||
unsubURL string
|
from string
|
||||||
from string
|
to string
|
||||||
to string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config has parameters for configuring the manager.
|
// Config has parameters for configuring the manager.
|
||||||
|
@ -76,6 +75,7 @@ type Config struct {
|
||||||
FromEmail string
|
FromEmail string
|
||||||
LinkTrackURL string
|
LinkTrackURL string
|
||||||
UnsubURL string
|
UnsubURL string
|
||||||
|
OptinURL string
|
||||||
ViewTrackURL string
|
ViewTrackURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,9 +108,8 @@ func (m *Manager) NewMessage(c *models.Campaign, s *models.Subscriber) *Message
|
||||||
Campaign: c,
|
Campaign: c,
|
||||||
Subscriber: s,
|
Subscriber: s,
|
||||||
|
|
||||||
from: c.FromEmail,
|
from: c.FromEmail,
|
||||||
to: s.Email,
|
to: s.Email,
|
||||||
unsubURL: fmt.Sprintf(m.cfg.UnsubURL, c.UUID, s.UUID),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -423,7 +422,12 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
||||||
fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, msg.Subscriber.UUID)))
|
fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, msg.Subscriber.UUID)))
|
||||||
},
|
},
|
||||||
"UnsubscribeURL": func(msg *Message) string {
|
"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 {
|
"Date": func(layout string) string {
|
||||||
if layout == "" {
|
if layout == "" {
|
||||||
|
|
|
@ -23,6 +23,11 @@ const (
|
||||||
SubscriberStatusDisabled = "disabled"
|
SubscriberStatusDisabled = "disabled"
|
||||||
SubscriberStatusBlackListed = "blacklisted"
|
SubscriberStatusBlackListed = "blacklisted"
|
||||||
|
|
||||||
|
// Subscription.
|
||||||
|
SubscriptionStatusUnconfirmed = "unconfirmed"
|
||||||
|
SubscriptionStatusConfirmed = "confirmed"
|
||||||
|
SubscriptionStatusUnsubscribed = "unsubscribed"
|
||||||
|
|
||||||
// Campaign.
|
// Campaign.
|
||||||
CampaignStatusDraft = "draft"
|
CampaignStatusDraft = "draft"
|
||||||
CampaignStatusScheduled = "scheduled"
|
CampaignStatusScheduled = "scheduled"
|
||||||
|
@ -34,6 +39,8 @@ const (
|
||||||
// List.
|
// List.
|
||||||
ListTypePrivate = "private"
|
ListTypePrivate = "private"
|
||||||
ListTypePublic = "public"
|
ListTypePublic = "public"
|
||||||
|
ListOptinSingle = "single"
|
||||||
|
ListOptinDouble = "double"
|
||||||
|
|
||||||
// User.
|
// User.
|
||||||
UserTypeSuperadmin = "superadmin"
|
UserTypeSuperadmin = "superadmin"
|
||||||
|
@ -72,7 +79,7 @@ var regTplFuncs = []regTplFunc{
|
||||||
|
|
||||||
// AdminNotifCallback is a callback function that's called
|
// AdminNotifCallback is a callback function that's called
|
||||||
// when a campaign's status changes.
|
// 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.
|
// Base holds common fields shared across models.
|
||||||
type Base struct {
|
type Base struct {
|
||||||
|
@ -126,6 +133,7 @@ type List struct {
|
||||||
UUID string `db:"uuid" json:"uuid"`
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
Type string `db:"type" json:"type"`
|
Type string `db:"type" json:"type"`
|
||||||
|
Optin string `db:"optin" json:"optin"`
|
||||||
Tags pq.StringArray `db:"tags" json:"tags"`
|
Tags pq.StringArray `db:"tags" json:"tags"`
|
||||||
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
|
SubscriberCount int `db:"subscriber_count" json:"subscriber_count"`
|
||||||
SubscriberID int `db:"subscriber_id" json:"-"`
|
SubscriberID int `db:"subscriber_id" json:"-"`
|
||||||
|
@ -306,7 +314,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
||||||
// FirstName splits the name by spaces and returns the first chunk
|
// FirstName splits the name by spaces and returns the first chunk
|
||||||
// of the name that's greater than 2 characters in length, assuming
|
// of the name that's greater than 2 characters in length, assuming
|
||||||
// that it is the subscriber's first name.
|
// 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, " ") {
|
for _, s := range strings.Split(s.Name, " ") {
|
||||||
if len(s) > 2 {
|
if len(s) > 2 {
|
||||||
return s
|
return s
|
||||||
|
@ -319,7 +327,7 @@ func (s *Subscriber) FirstName() string {
|
||||||
// LastName splits the name by spaces and returns the last chunk
|
// LastName splits the name by spaces and returns the last chunk
|
||||||
// of the name that's greater than 2 characters in length, assuming
|
// of the name that's greater than 2 characters in length, assuming
|
||||||
// that it is the subscriber's last name.
|
// that it is the subscriber's last name.
|
||||||
func (s *Subscriber) LastName() string {
|
func (s Subscriber) LastName() string {
|
||||||
chunks := strings.Split(s.Name, " ")
|
chunks := strings.Split(s.Name, " ")
|
||||||
for i := len(chunks) - 1; i >= 0; i-- {
|
for i := len(chunks) - 1; i >= 0; i-- {
|
||||||
chunk := chunks[i]
|
chunk := chunks[i]
|
||||||
|
|
|
@ -2,25 +2,35 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
|
||||||
|
"github.com/knadh/stuffbin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
notifTplImport = "import-status"
|
notifTplImport = "import-status"
|
||||||
notifTplCampaign = "campaign-status"
|
notifTplCampaign = "campaign-status"
|
||||||
|
notifSubscriberOptin = "subscriber-optin"
|
||||||
|
notifSubscriberData = "subscriber-data"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sendNotification sends out an e-mail notification to admins.
|
// notifData represents params commonly used across different notification
|
||||||
func sendNotification(tpl, subject string, data map[string]interface{}, app *App) error {
|
// templates.
|
||||||
data["RootURL"] = app.Constants.RootURL
|
type notifData struct {
|
||||||
|
RootURL string
|
||||||
|
LogoURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNotification sends out an e-mail notification to admins.
|
||||||
|
func sendNotification(toEmails []string, subject, tplName string, data interface{}, app *App) error {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
|
if err := app.NotifTpls.ExecuteTemplate(&b, tplName, data); err != nil {
|
||||||
if err != nil {
|
app.Logger.Printf("error compiling notification template '%s': %v", tplName, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = app.Messenger.Push(app.Constants.FromEmail,
|
err := app.Messenger.Push(app.Constants.FromEmail,
|
||||||
app.Constants.NotifyEmails,
|
toEmails,
|
||||||
subject,
|
subject,
|
||||||
b.Bytes(),
|
b.Bytes(),
|
||||||
nil)
|
nil)
|
||||||
|
@ -28,21 +38,25 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
|
||||||
app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
|
app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getNotificationTemplate(tpl string, data map[string]interface{}, app *App) ([]byte, error) {
|
// compileNotifTpls compiles and returns e-mail notification templates that are
|
||||||
if data == nil {
|
// used for sending ad-hoc notifications to admins and subscribers.
|
||||||
data = make(map[string]interface{})
|
func compileNotifTpls(path string, fs stuffbin.FileSystem, app *App) (*template.Template, error) {
|
||||||
}
|
// Register utility functions that the e-mail templates can use.
|
||||||
data["RootURL"] = app.Constants.RootURL
|
funcs := template.FuncMap{
|
||||||
|
"RootURL": func() string {
|
||||||
|
return app.Constants.RootURL
|
||||||
|
},
|
||||||
|
"LogoURL": func() string {
|
||||||
|
return app.Constants.LogoURL
|
||||||
|
}}
|
||||||
|
|
||||||
var b bytes.Buffer
|
tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/email-templates/*.html")
|
||||||
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.Bytes(), err
|
return tpl, err
|
||||||
}
|
}
|
||||||
|
|
83
public.go
83
public.go
|
@ -10,6 +10,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/knadh/listmonk/messenger"
|
"github.com/knadh/listmonk/messenger"
|
||||||
|
"github.com/knadh/listmonk/models"
|
||||||
"github.com/labstack/echo"
|
"github.com/labstack/echo"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
@ -44,6 +45,13 @@ type unsubTpl struct {
|
||||||
AllowWipe bool
|
AllowWipe bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type optinTpl struct {
|
||||||
|
publicTpl
|
||||||
|
SubUUID string
|
||||||
|
ListUUIDs []string `query:"l" form:"l"`
|
||||||
|
Lists []models.List `query:"-" form:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type msgTpl struct {
|
type msgTpl struct {
|
||||||
publicTpl
|
publicTpl
|
||||||
MessageTitle string
|
MessageTitle string
|
||||||
|
@ -102,6 +110,73 @@ func handleSubscriptionPage(c echo.Context) error {
|
||||||
return c.Render(http.StatusOK, "subscription", out)
|
return c.Render(http.StatusOK, "subscription", out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleOptinPage handles a double opt-in confirmation from subscribers.
|
||||||
|
func handleOptinPage(c echo.Context) error {
|
||||||
|
var (
|
||||||
|
app = c.Get("app").(*App)
|
||||||
|
subUUID = c.Param("subUUID")
|
||||||
|
confirm, _ = strconv.ParseBool(c.FormValue("confirm"))
|
||||||
|
out = optinTpl{}
|
||||||
|
)
|
||||||
|
out.SubUUID = subUUID
|
||||||
|
out.Title = "Confirm subscriptions"
|
||||||
|
out.SubUUID = subUUID
|
||||||
|
|
||||||
|
// Get and validate fields.
|
||||||
|
if err := c.Bind(&out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate list UUIDs if there are incoming UUIDs in the request.
|
||||||
|
if len(out.ListUUIDs) > 0 {
|
||||||
|
for _, l := range out.ListUUIDs {
|
||||||
|
if !reUUID.MatchString(l) {
|
||||||
|
return c.Render(http.StatusBadRequest, "message",
|
||||||
|
makeMsgTpl("Invalid request", "",
|
||||||
|
`One or more UUIDs in the request are invalid.`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lists by UUIDs.
|
||||||
|
if err := app.Queries.GetListsByUUID.Select(&out.Lists, 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.`))
|
||||||
|
}
|
||||||
|
} 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.`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are no lists to confirm.
|
||||||
|
if len(out.Lists) == 0 {
|
||||||
|
return c.Render(http.StatusInternalServerError, "message",
|
||||||
|
makeMsgTpl("No subscriptions", "",
|
||||||
|
`There are no subscriptions to confirm.`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm.
|
||||||
|
if confirm {
|
||||||
|
if _, err := app.Queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
|
||||||
|
app.Logger.Printf("error unsubscribing: %v", err)
|
||||||
|
return c.Render(http.StatusInternalServerError, "message",
|
||||||
|
makeMsgTpl("Error", "",
|
||||||
|
`Error processing request. Please retry.`))
|
||||||
|
}
|
||||||
|
return c.Render(http.StatusOK, "message",
|
||||||
|
makeMsgTpl("Confirmed", "",
|
||||||
|
`Your subscriptions have been confirmed.`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Render(http.StatusOK, "optin", out)
|
||||||
|
}
|
||||||
|
|
||||||
// handleLinkRedirect handles link UUID to real link redirection.
|
// handleLinkRedirect handles link UUID to real link redirection.
|
||||||
func handleLinkRedirect(c echo.Context) error {
|
func handleLinkRedirect(c echo.Context) error {
|
||||||
var (
|
var (
|
||||||
|
@ -166,9 +241,9 @@ func handleSelfExportSubscriberData(c echo.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the data out to the subscriber as an atachment.
|
// Send the data out to the subscriber as an atachment.
|
||||||
msg, err := getNotificationTemplate("subscriber-data", nil, app)
|
var msg bytes.Buffer
|
||||||
if err != nil {
|
if err := app.NotifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
|
||||||
app.Logger.Printf("error preparing subscriber data e-mail template: %s", err)
|
app.Logger.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
|
||||||
return c.Render(http.StatusInternalServerError, "message",
|
return c.Render(http.StatusInternalServerError, "message",
|
||||||
makeMsgTpl("Error preparing data", "",
|
makeMsgTpl("Error preparing data", "",
|
||||||
"There was an error preparing your data. Please try later."))
|
"There was an error preparing your data. Please try later."))
|
||||||
|
@ -178,7 +253,7 @@ func handleSelfExportSubscriberData(c echo.Context) error {
|
||||||
if err := app.Messenger.Push(app.Constants.FromEmail,
|
if err := app.Messenger.Push(app.Constants.FromEmail,
|
||||||
[]string{data.Email},
|
[]string{data.Email},
|
||||||
"Your profile data",
|
"Your profile data",
|
||||||
msg,
|
msg.Bytes(),
|
||||||
[]*messenger.Attachment{
|
[]*messenger.Attachment{
|
||||||
&messenger.Attachment{
|
&messenger.Attachment{
|
||||||
Name: fname,
|
Name: fname,
|
||||||
|
|
|
@ -175,6 +175,11 @@
|
||||||
}
|
}
|
||||||
} /*# sourceMappingURL=dist/flexit.min.css.map */
|
} /*# sourceMappingURL=dist/flexit.min.css.map */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
@ -235,7 +240,9 @@ section {
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
margin-bottom: 60px;
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
.header .logo img {
|
.header .logo img {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
@ -266,8 +273,6 @@ section {
|
||||||
@media screen and (max-width: 650px) {
|
@media screen and (max-width: 650px) {
|
||||||
.wrap {
|
.wrap {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
padding: 30px;
|
||||||
.header {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
{{ define "optin" }}
|
||||||
|
{{ template "header" .}}
|
||||||
|
<section>
|
||||||
|
<h2>Confirm</h2>
|
||||||
|
<p>
|
||||||
|
You have been added to the following mailing lists:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<ul>
|
||||||
|
{{ range $i, $l := .Data.Lists }}
|
||||||
|
<input type="hidden" name="l" value="{{ $l.UUID }}" />
|
||||||
|
{{ if eq $l.Type "public" }}
|
||||||
|
<li>{{ $l.Name }}</li>
|
||||||
|
{{ else }}
|
||||||
|
<li>Private list</li>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<input type="hidden" name="confirm" value="true" />
|
||||||
|
<button type="submit" class="button" id="btn-unsub">Confirm subscription(s)</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ template "footer" .}}
|
||||||
|
{{ end }}
|
|
@ -19,11 +19,13 @@ type Queries struct {
|
||||||
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
|
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
|
||||||
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
|
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
|
||||||
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
|
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
|
||||||
|
GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
|
||||||
SubscriberExists *sqlx.Stmt `query:"subscriber-exists"`
|
SubscriberExists *sqlx.Stmt `query:"subscriber-exists"`
|
||||||
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
|
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
|
||||||
BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"`
|
BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"`
|
||||||
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
|
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
|
||||||
DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"`
|
DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"`
|
||||||
|
ConfirmSubscriptionOptin *sqlx.Stmt `query:"confirm-subscription-optin"`
|
||||||
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
|
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
|
||||||
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
|
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
|
||||||
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
|
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
|
||||||
|
@ -40,6 +42,8 @@ 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"`
|
||||||
|
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"`
|
||||||
|
|
109
queries.sql
109
queries.sql
|
@ -12,6 +12,15 @@ SELECT exists (SELECT true FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1
|
||||||
SELECT * FROM subscribers WHERE email=ANY($1);
|
SELECT * FROM subscribers WHERE email=ANY($1);
|
||||||
|
|
||||||
-- name: get-subscriber-lists
|
-- name: get-subscriber-lists
|
||||||
|
WITH sub AS (
|
||||||
|
SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
|
||||||
|
)
|
||||||
|
SELECT * FROM lists
|
||||||
|
LEFT JOIN subscriber_lists ON (lists.id = subscriber_lists.list_id)
|
||||||
|
WHERE subscriber_id = (SELECT id FROM sub)
|
||||||
|
AND (CASE WHEN $3 != '' THEN subscriber_lists.status = $3::subscription_status END);
|
||||||
|
|
||||||
|
-- name: get-subscriber-lists-lazy
|
||||||
-- Get lists associations of subscribers given a list of subscriber IDs.
|
-- Get lists associations of subscribers given a list of subscriber IDs.
|
||||||
-- This query is used to lazy load given a list of subscriber IDs.
|
-- This query is used to lazy load given a list of subscriber IDs.
|
||||||
-- The query returns results in the same order as the given subscriber IDs, and for non-existent subscriber IDs,
|
-- The query returns results in the same order as the given subscriber IDs, and for non-existent subscriber IDs,
|
||||||
|
@ -130,6 +139,16 @@ INSERT INTO subscriber_lists (subscriber_id, list_id)
|
||||||
DELETE FROM subscriber_lists
|
DELETE FROM subscriber_lists
|
||||||
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b);
|
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b);
|
||||||
|
|
||||||
|
-- name: confirm-subscription-optin
|
||||||
|
WITH subID AS (
|
||||||
|
SELECT id FROM subscribers WHERE uuid = $1::UUID
|
||||||
|
),
|
||||||
|
listIDs AS (
|
||||||
|
SELECT id FROM lists WHERE uuid = ANY($2::UUID[])
|
||||||
|
)
|
||||||
|
UPDATE subscriber_lists SET status='confirmed', updated_at=NOW()
|
||||||
|
WHERE subscriber_id = (SELECT id FROM subID) AND list_id = ANY(SELECT id FROM listIDs);
|
||||||
|
|
||||||
-- name: unsubscribe-subscribers-from-lists
|
-- name: unsubscribe-subscribers-from-lists
|
||||||
UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
|
UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
|
||||||
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b);
|
WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b);
|
||||||
|
@ -275,14 +294,21 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
|
||||||
WHERE ($1 = 0 OR id = $1)
|
WHERE ($1 = 0 OR id = $1)
|
||||||
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
|
||||||
|
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;
|
||||||
|
|
||||||
-- name: create-list
|
-- name: create-list
|
||||||
INSERT INTO lists (uuid, name, type, tags) VALUES($1, $2, $3, $4) RETURNING id;
|
INSERT INTO lists (uuid, name, type, optin, tags) VALUES($1, $2, $3, $4, $5) RETURNING id;
|
||||||
|
|
||||||
-- name: update-list
|
-- name: update-list
|
||||||
UPDATE lists SET
|
UPDATE lists SET
|
||||||
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
|
name=(CASE WHEN $2 != '' THEN $2 ELSE name END),
|
||||||
type=(CASE WHEN $3 != '' THEN $3::list_type ELSE type END),
|
type=(CASE WHEN $3 != '' THEN $3::list_type ELSE type END),
|
||||||
tags=(CASE WHEN ARRAY_LENGTH($4::VARCHAR(100)[], 1) > 0 THEN $4 ELSE tags END),
|
optin=(CASE WHEN $4 != '' THEN $4::list_optin ELSE optin END),
|
||||||
|
tags=(CASE WHEN ARRAY_LENGTH($5::VARCHAR(100)[], 1) > 0 THEN $5 ELSE tags END),
|
||||||
updated_at=NOW()
|
updated_at=NOW()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
@ -296,10 +322,25 @@ DELETE FROM lists WHERE id = ALL($1);
|
||||||
-- campaigns
|
-- campaigns
|
||||||
-- name: create-campaign
|
-- name: create-campaign
|
||||||
-- This creates the campaign and inserts campaign_lists relationships.
|
-- This creates the campaign and inserts campaign_lists relationships.
|
||||||
WITH counts AS (
|
WITH campLists AS (
|
||||||
|
-- Get the list_ids and their optin statuses for the campaigns found in the previous step.
|
||||||
|
SELECT id AS list_id, campaign_id, optin FROM lists
|
||||||
|
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
|
||||||
|
WHERE id=ANY($11::INT[])
|
||||||
|
),
|
||||||
|
counts AS (
|
||||||
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
|
SELECT COALESCE(COUNT(id), 0) as to_send, COALESCE(MAX(id), 0) as max_sub_id
|
||||||
FROM subscribers
|
FROM subscribers
|
||||||
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
|
LEFT JOIN campLists ON (campLists.campaign_id = ANY($11::INT[]))
|
||||||
|
LEFT JOIN subscriber_lists ON (
|
||||||
|
subscriber_lists.status != 'unsubscribed' AND
|
||||||
|
subscribers.id = subscriber_lists.subscriber_id AND
|
||||||
|
subscriber_lists.list_id = campLists.list_id AND
|
||||||
|
|
||||||
|
-- For double opt-in lists, consider only 'confirmed' subscriptions. For single opt-ins,
|
||||||
|
-- any status except for 'unsubscribed' (already excluded above) works.
|
||||||
|
(CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
|
||||||
|
)
|
||||||
WHERE subscriber_lists.list_id=ANY($11::INT[])
|
WHERE subscriber_lists.list_id=ANY($11::INT[])
|
||||||
AND subscribers.status='enabled'
|
AND subscribers.status='enabled'
|
||||||
),
|
),
|
||||||
|
@ -398,17 +439,32 @@ WITH camps AS (
|
||||||
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
|
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
|
||||||
AND NOT(campaigns.id = ANY($1::INT[]))
|
AND NOT(campaigns.id = ANY($1::INT[]))
|
||||||
),
|
),
|
||||||
counts AS (
|
campLists AS (
|
||||||
-- For each campaign above, get the total number of subscribers and the max_subscriber_id across all its lists.
|
-- Get the list_ids and their optin statuses for the campaigns found in the previous step.
|
||||||
SELECT id AS campaign_id, COUNT(subscriber_lists.subscriber_id) AS to_send,
|
SELECT id AS list_id, campaign_id, optin FROM lists
|
||||||
COALESCE(MAX(subscriber_lists.subscriber_id), 0) AS max_subscriber_id FROM camps
|
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
|
||||||
LEFT JOIN campaign_lists ON (campaign_lists.campaign_id = camps.id)
|
|
||||||
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = campaign_lists.list_id AND subscriber_lists.status != 'unsubscribed')
|
|
||||||
WHERE campaign_lists.campaign_id = ANY(SELECT id FROM camps)
|
WHERE campaign_lists.campaign_id = ANY(SELECT id FROM camps)
|
||||||
|
),
|
||||||
|
counts AS (
|
||||||
|
-- For each campaign above, get the total number of subscribers and the max_subscriber_id
|
||||||
|
-- across all its lists.
|
||||||
|
SELECT id AS campaign_id,
|
||||||
|
COUNT(DISTINCT(subscriber_lists.subscriber_id)) AS to_send,
|
||||||
|
COALESCE(MAX(subscriber_lists.subscriber_id), 0) AS max_subscriber_id
|
||||||
|
FROM camps
|
||||||
|
LEFT JOIN campLists ON (campLists.campaign_id = camps.id)
|
||||||
|
LEFT JOIN subscriber_lists ON (
|
||||||
|
subscriber_lists.status != 'unsubscribed' AND
|
||||||
|
subscriber_lists.list_id = campLists.list_id AND
|
||||||
|
|
||||||
|
-- For double opt-in lists, consider only 'confirmed' subscriptions. For single opt-ins,
|
||||||
|
-- any status except for 'unsubscribed' (already excluded above) works.
|
||||||
|
(CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
|
||||||
|
)
|
||||||
GROUP BY camps.id
|
GROUP BY camps.id
|
||||||
),
|
),
|
||||||
u AS (
|
u AS (
|
||||||
-- For each campaign above, update the to_send count.
|
-- For each campaign, update the to_send count and set the max_subscriber_id.
|
||||||
UPDATE campaigns AS ca
|
UPDATE campaigns AS ca
|
||||||
SET to_send = co.to_send,
|
SET to_send = co.to_send,
|
||||||
status = (CASE WHEN status != 'running' THEN 'running' ELSE status END),
|
status = (CASE WHEN status != 'running' THEN 'running' ELSE status END),
|
||||||
|
@ -423,27 +479,36 @@ SELECT * FROM camps;
|
||||||
-- Returns a batch of subscribers in a given campaign starting from the last checkpoint
|
-- Returns a batch of subscribers in a given campaign starting from the last checkpoint
|
||||||
-- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means
|
-- (last_subscriber_id). Every fetch updates the checkpoint and the sent count, which means
|
||||||
-- every fetch returns a new batch of subscribers until all rows are exhausted.
|
-- every fetch returns a new batch of subscribers until all rows are exhausted.
|
||||||
WITH camp AS (
|
WITH camps AS (
|
||||||
SELECT last_subscriber_id, max_subscriber_id
|
SELECT last_subscriber_id, max_subscriber_id
|
||||||
FROM campaigns
|
FROM campaigns
|
||||||
WHERE id=$1 AND status='running'
|
WHERE id=$1 AND status='running'
|
||||||
),
|
),
|
||||||
|
campLists AS (
|
||||||
|
SELECT id AS list_id, optin FROM lists
|
||||||
|
INNER JOIN campaign_lists ON (campaign_lists.list_id = lists.id)
|
||||||
|
WHERE campaign_lists.campaign_id = $1
|
||||||
|
),
|
||||||
subs AS (
|
subs AS (
|
||||||
SELECT DISTINCT ON(id) id AS uniq_id, * FROM subscribers
|
SELECT DISTINCT ON(subscribers.id) id AS uniq_id, subscribers.* FROM subscriber_lists
|
||||||
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id AND subscriber_lists.status != 'unsubscribed')
|
INNER JOIN campLists ON (
|
||||||
WHERE subscriber_lists.list_id=ANY(
|
campLists.list_id = subscriber_lists.list_id
|
||||||
SELECT list_id FROM campaign_lists where campaign_id=$1 AND list_id IS NOT NULL
|
|
||||||
)
|
)
|
||||||
AND subscribers.status != 'blacklisted'
|
INNER JOIN subscribers ON (
|
||||||
AND id > (SELECT last_subscriber_id FROM camp)
|
subscribers.status != 'blacklisted' AND
|
||||||
AND id <= (SELECT max_subscriber_id FROM camp)
|
subscribers.id = subscriber_lists.subscriber_id AND
|
||||||
|
(CASE WHEN campLists.optin = 'double' THEN subscriber_lists.status = 'confirmed' ELSE true END)
|
||||||
|
)
|
||||||
|
WHERE subscriber_lists.status != 'unsubscribed' AND
|
||||||
|
id > (SELECT last_subscriber_id FROM camps) AND
|
||||||
|
id <= (SELECT max_subscriber_id FROM camps)
|
||||||
ORDER BY id LIMIT $2
|
ORDER BY id LIMIT $2
|
||||||
),
|
),
|
||||||
u AS (
|
u AS (
|
||||||
UPDATE campaigns
|
UPDATE campaigns
|
||||||
SET last_subscriber_id=(SELECT MAX(id) FROM subs),
|
SET last_subscriber_id = (SELECT MAX(id) FROM subs),
|
||||||
sent=sent + (SELECT COUNT(id) FROM subs),
|
sent = sent + (SELECT COUNT(id) FROM subs),
|
||||||
updated_at=NOW()
|
updated_at = NOW()
|
||||||
WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1
|
WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1
|
||||||
)
|
)
|
||||||
SELECT * FROM subs;
|
SELECT * FROM subs;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
DROP TYPE IF EXISTS list_type CASCADE; CREATE TYPE list_type AS ENUM ('public', 'private', 'temporary');
|
DROP TYPE IF EXISTS list_type CASCADE; CREATE TYPE list_type AS ENUM ('public', 'private', 'temporary');
|
||||||
|
DROP TYPE IF EXISTS list_optin CASCADE; CREATE TYPE list_optin AS ENUM ('single', 'double');
|
||||||
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');
|
||||||
|
@ -28,6 +29,7 @@ CREATE TABLE lists (
|
||||||
uuid uuid NOT NULL UNIQUE,
|
uuid uuid NOT NULL UNIQUE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type list_type NOT NULL,
|
type list_type NOT NULL,
|
||||||
|
optin list_optin NOT NULL DEFAULT 'single',
|
||||||
tags VARCHAR(100)[],
|
tags VARCHAR(100)[],
|
||||||
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -46,6 +47,14 @@ type subProfileData struct {
|
||||||
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
|
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subOptin contains the data that's passed to the double opt-in e-mail template.
|
||||||
|
type subOptin struct {
|
||||||
|
*models.Subscriber
|
||||||
|
|
||||||
|
OptinURL string
|
||||||
|
Lists []models.List
|
||||||
|
}
|
||||||
|
|
||||||
var dummySubscriber = models.Subscriber{
|
var dummySubscriber = models.Subscriber{
|
||||||
Email: "dummy@listmonk.app",
|
Email: "dummy@listmonk.app",
|
||||||
Name: "Dummy Subscriber",
|
Name: "Dummy Subscriber",
|
||||||
|
@ -73,7 +82,7 @@ func handleGetSubscriber(c echo.Context) error {
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
|
return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
|
||||||
}
|
}
|
||||||
if err := out.LoadLists(app.Queries.GetSubscriberLists); err != nil {
|
if err := out.LoadLists(app.Queries.GetSubscriberListsLazy); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Error loading lists for subscriber.")
|
return echo.NewHTTPError(http.StatusInternalServerError, "Error loading lists for subscriber.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,7 +132,7 @@ func handleQuerySubscribers(c echo.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy load lists for each subscriber.
|
// Lazy load lists for each subscriber.
|
||||||
if err := out.Results.LoadLists(app.Queries.GetSubscriberLists); err != nil {
|
if err := out.Results.LoadLists(app.Queries.GetSubscriberListsLazy); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
|
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
|
||||||
}
|
}
|
||||||
|
@ -157,10 +166,14 @@ func handleCreateSubscriber(c echo.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert and read ID.
|
// Insert and read ID.
|
||||||
var newID int
|
var (
|
||||||
|
newID int
|
||||||
|
email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
)
|
||||||
|
req.UUID = uuid.NewV4().String()
|
||||||
err := app.Queries.InsertSubscriber.Get(&newID,
|
err := app.Queries.InsertSubscriber.Get(&newID,
|
||||||
uuid.NewV4(),
|
req.UUID,
|
||||||
strings.ToLower(strings.TrimSpace(req.Email)),
|
email,
|
||||||
strings.TrimSpace(req.Name),
|
strings.TrimSpace(req.Name),
|
||||||
req.Status,
|
req.Status,
|
||||||
req.Attribs,
|
req.Attribs,
|
||||||
|
@ -169,11 +182,13 @@ func handleCreateSubscriber(c echo.Context) error {
|
||||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
|
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
|
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
fmt.Sprintf("Error creating subscriber: %v", err))
|
fmt.Sprintf("Error creating subscriber: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the lists are double-optins, send confirmation e-mails.
|
||||||
|
go sendOptinConfirmation(req.Subscriber, []int64(req.Lists), app)
|
||||||
|
|
||||||
// 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))
|
||||||
|
@ -503,6 +518,44 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool,
|
||||||
return data, b, nil
|
return data, b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendOptinConfirmation sends double opt-in confirmation e-mails to a subscriber
|
||||||
|
// if at least one of the given listIDs is set to optin=double
|
||||||
|
func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) error {
|
||||||
|
var lists []models.List
|
||||||
|
|
||||||
|
// Fetch double opt-in lists from the given list IDs.
|
||||||
|
err := app.Queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(listIDs))
|
||||||
|
if err != nil {
|
||||||
|
app.Logger.Printf("error fetching lists for optin: %s", pqErrMsg(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// None.
|
||||||
|
if len(lists) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
out = subOptin{Subscriber: &sub, Lists: lists}
|
||||||
|
qListIDs = url.Values{}
|
||||||
|
)
|
||||||
|
// Construct the opt-in URL with list IDs.
|
||||||
|
for _, l := range out.Lists {
|
||||||
|
qListIDs.Add("l", l.UUID)
|
||||||
|
}
|
||||||
|
out.OptinURL = fmt.Sprintf(app.Constants.OptinURL, sub.UUID, qListIDs.Encode())
|
||||||
|
|
||||||
|
// Send the e-mail.
|
||||||
|
if err := sendNotification([]string{sub.Email},
|
||||||
|
"Confirm subscription",
|
||||||
|
notifSubscriberOptin, out, app); err != nil {
|
||||||
|
app.Logger.Printf("error e-mailing subscriber profile: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// sanitizeSQLExp does basic sanitisation on arbitrary
|
// sanitizeSQLExp does basic sanitisation on arbitrary
|
||||||
// SQL query expressions coming from the frontend.
|
// SQL query expressions coming from the frontend.
|
||||||
func sanitizeSQLExp(q string) string {
|
func sanitizeSQLExp(q string) string {
|
||||||
|
|
Loading…
Reference in New Issue