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
18 changed files with 425 additions and 96 deletions
21
email-templates/subscriber-optin.html
Normal file
21
email-templates/subscriber-optin.html
Normal file
|
@ -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">
|
||||
<span>email,</span>
|
||||
<span>name,</span>
|
||||
<span>status,</span>
|
||||
<span>attributes</span>
|
||||
</code>
|
||||
<code className="csv-row">
|
||||
<span>user1@mail.com,</span>
|
||||
<span>"User One",</span>
|
||||
<span>enabled,</span>
|
||||
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
|
||||
</code>
|
||||
<code className="csv-row">
|
||||
<span>user2@mail.com,</span>
|
||||
<span>"User Two",</span>
|
||||
<span>blacklisted,</span>
|
||||
<span>
|
||||
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
|
||||
</span>
|
||||
|
|
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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 (
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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.
|
||||
|
|
3
lists.go
3
lists.go
|
@ -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)))
|
||||
|
@ -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)))
|
||||
|
|
44
main.go
44
main.go
|
@ -33,6 +33,10 @@ type constants struct {
|
|||
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"`
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
|
|
@ -63,7 +63,6 @@ type Message struct {
|
|||
Subscriber *models.Subscriber
|
||||
Body []byte
|
||||
|
||||
unsubURL string
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
@ -76,6 +75,7 @@ type Config struct {
|
|||
FromEmail string
|
||||
LinkTrackURL string
|
||||
UnsubURL string
|
||||
OptinURL string
|
||||
ViewTrackURL string
|
||||
}
|
||||
|
||||
|
@ -110,7 +110,6 @@ func (m *Manager) NewMessage(c *models.Campaign, s *models.Subscriber) *Message
|
|||
|
||||
from: c.FromEmail,
|
||||
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)))
|
||||
},
|
||||
"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 == "" {
|
||||
|
|
|
@ -23,6 +23,11 @@ const (
|
|||
SubscriberStatusDisabled = "disabled"
|
||||
SubscriberStatusBlackListed = "blacklisted"
|
||||
|
||||
// Subscription.
|
||||
SubscriptionStatusUnconfirmed = "unconfirmed"
|
||||
SubscriptionStatusConfirmed = "confirmed"
|
||||
SubscriptionStatusUnsubscribed = "unsubscribed"
|
||||
|
||||
// Campaign.
|
||||
CampaignStatusDraft = "draft"
|
||||
CampaignStatusScheduled = "scheduled"
|
||||
|
@ -34,6 +39,8 @@ const (
|
|||
// List.
|
||||
ListTypePrivate = "private"
|
||||
ListTypePublic = "public"
|
||||
ListOptinSingle = "single"
|
||||
ListOptinDouble = "double"
|
||||
|
||||
// User.
|
||||
UserTypeSuperadmin = "superadmin"
|
||||
|
@ -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 {
|
||||
|
@ -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:"-"`
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
|
|
@ -2,25 +2,35 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
const (
|
||||
notifTplImport = "import-status"
|
||||
notifTplCampaign = "campaign-status"
|
||||
notifSubscriberOptin = "subscriber-optin"
|
||||
notifSubscriberData = "subscriber-data"
|
||||
)
|
||||
|
||||
// sendNotification sends out an e-mail notification to admins.
|
||||
func sendNotification(tpl, subject string, data map[string]interface{}, app *App) error {
|
||||
data["RootURL"] = app.Constants.RootURL
|
||||
// notifData represents params commonly used across different notification
|
||||
// templates.
|
||||
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
|
||||
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
|
||||
if err != nil {
|
||||
if err := app.NotifTpls.ExecuteTemplate(&b, tplName, data); err != nil {
|
||||
app.Logger.Printf("error compiling notification template '%s': %v", tplName, err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = app.Messenger.Push(app.Constants.FromEmail,
|
||||
app.Constants.NotifyEmails,
|
||||
err := app.Messenger.Push(app.Constants.FromEmail,
|
||||
toEmails,
|
||||
subject,
|
||||
b.Bytes(),
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getNotificationTemplate(tpl string, data map[string]interface{}, app *App) ([]byte, error) {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
data["RootURL"] = app.Constants.RootURL
|
||||
// compileNotifTpls compiles and returns e-mail notification templates that are
|
||||
// used for sending ad-hoc notifications to admins and subscribers.
|
||||
func compileNotifTpls(path string, fs stuffbin.FileSystem, app *App) (*template.Template, error) {
|
||||
// Register utility functions that the e-mail templates can use.
|
||||
funcs := template.FuncMap{
|
||||
"RootURL": func() string {
|
||||
return app.Constants.RootURL
|
||||
},
|
||||
"LogoURL": func() string {
|
||||
return app.Constants.LogoURL
|
||||
}}
|
||||
|
||||
var b bytes.Buffer
|
||||
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
|
||||
tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/email-templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), err
|
||||
return tpl, err
|
||||
}
|
||||
|
|
83
public.go
83
public.go
|
@ -10,6 +10,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/knadh/listmonk/messenger"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
@ -44,6 +45,13 @@ type unsubTpl struct {
|
|||
AllowWipe bool
|
||||
}
|
||||
|
||||
type optinTpl struct {
|
||||
publicTpl
|
||||
SubUUID string
|
||||
ListUUIDs []string `query:"l" form:"l"`
|
||||
Lists []models.List `query:"-" form:"-"`
|
||||
}
|
||||
|
||||
type msgTpl struct {
|
||||
publicTpl
|
||||
MessageTitle string
|
||||
|
@ -102,6 +110,73 @@ func handleSubscriptionPage(c echo.Context) error {
|
|||
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.
|
||||
func handleLinkRedirect(c echo.Context) error {
|
||||
var (
|
||||
|
@ -166,9 +241,9 @@ func handleSelfExportSubscriberData(c echo.Context) error {
|
|||
}
|
||||
|
||||
// Send the data out to the subscriber as an atachment.
|
||||
msg, err := getNotificationTemplate("subscriber-data", nil, app)
|
||||
if err != nil {
|
||||
app.Logger.Printf("error preparing subscriber data e-mail template: %s", err)
|
||||
var msg bytes.Buffer
|
||||
if err := app.NotifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
|
||||
app.Logger.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error preparing data", "",
|
||||
"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,
|
||||
[]string{data.Email},
|
||||
"Your profile data",
|
||||
msg,
|
||||
msg.Bytes(),
|
||||
[]*messenger.Attachment{
|
||||
&messenger.Attachment{
|
||||
Name: fname,
|
||||
|
|
|
@ -175,6 +175,11 @@
|
|||
}
|
||||
} /*# sourceMappingURL=dist/flexit.min.css.map */
|
||||
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
}
|
||||
body {
|
||||
background: #f9f9f9;
|
||||
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
||||
|
@ -235,7 +240,9 @@ section {
|
|||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 60px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header .logo img {
|
||||
width: auto;
|
||||
|
@ -266,8 +273,6 @@ section {
|
|||
@media screen and (max-width: 650px) {
|
||||
.wrap {
|
||||
margin: 0;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 30px;
|
||||
padding: 30px;
|
||||
}
|
||||
}
|
||||
|
|
28
public/templates/optin.html
Normal file
28
public/templates/optin.html
Normal file
|
@ -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"`
|
||||
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
|
||||
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
|
||||
GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
|
||||
SubscriberExists *sqlx.Stmt `query:"subscriber-exists"`
|
||||
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
|
||||
BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"`
|
||||
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
|
||||
DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"`
|
||||
ConfirmSubscriptionOptin *sqlx.Stmt `query:"confirm-subscription-optin"`
|
||||
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
|
||||
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
|
||||
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
|
||||
|
@ -40,6 +42,8 @@ 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"`
|
||||
|
|
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);
|
||||
|
||||
-- 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.
|
||||
-- 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,
|
||||
|
@ -130,6 +139,16 @@ INSERT INTO subscriber_lists (subscriber_id, list_id)
|
|||
DELETE FROM subscriber_lists
|
||||
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
|
||||
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);
|
||||
|
@ -275,14 +294,21 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
|
|||
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);
|
||||
|
||||
-- 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
|
||||
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
|
||||
UPDATE lists SET
|
||||
name=(CASE WHEN $2 != '' THEN $2 ELSE name 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()
|
||||
WHERE id = $1;
|
||||
|
||||
|
@ -296,10 +322,25 @@ DELETE FROM lists WHERE id = ALL($1);
|
|||
-- campaigns
|
||||
-- name: create-campaign
|
||||
-- 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
|
||||
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[])
|
||||
AND subscribers.status='enabled'
|
||||
),
|
||||
|
@ -398,17 +439,32 @@ WITH camps AS (
|
|||
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
|
||||
AND NOT(campaigns.id = ANY($1::INT[]))
|
||||
),
|
||||
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(subscriber_lists.subscriber_id) AS to_send,
|
||||
COALESCE(MAX(subscriber_lists.subscriber_id), 0) AS max_subscriber_id FROM camps
|
||||
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')
|
||||
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 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
|
||||
),
|
||||
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
|
||||
SET to_send = co.to_send,
|
||||
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
|
||||
-- (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.
|
||||
WITH camp AS (
|
||||
WITH camps AS (
|
||||
SELECT last_subscriber_id, max_subscriber_id
|
||||
FROM campaigns
|
||||
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 (
|
||||
SELECT DISTINCT ON(id) id AS uniq_id, * FROM subscribers
|
||||
LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id AND subscriber_lists.status != 'unsubscribed')
|
||||
WHERE subscriber_lists.list_id=ANY(
|
||||
SELECT list_id FROM campaign_lists where campaign_id=$1 AND list_id IS NOT NULL
|
||||
SELECT DISTINCT ON(subscribers.id) id AS uniq_id, subscribers.* FROM subscriber_lists
|
||||
INNER JOIN campLists ON (
|
||||
campLists.list_id = subscriber_lists.list_id
|
||||
)
|
||||
AND subscribers.status != 'blacklisted'
|
||||
AND id > (SELECT last_subscriber_id FROM camp)
|
||||
AND id <= (SELECT max_subscriber_id FROM camp)
|
||||
INNER JOIN subscribers ON (
|
||||
subscribers.status != 'blacklisted' AND
|
||||
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
|
||||
),
|
||||
u AS (
|
||||
UPDATE campaigns
|
||||
SET last_subscriber_id=(SELECT MAX(id) FROM subs),
|
||||
sent=sent + (SELECT COUNT(id) FROM subs),
|
||||
updated_at=NOW()
|
||||
SET last_subscriber_id = (SELECT MAX(id) FROM subs),
|
||||
sent = sent + (SELECT COUNT(id) FROM subs),
|
||||
updated_at = NOW()
|
||||
WHERE (SELECT COUNT(id) FROM subs) > 0 AND id=$1
|
||||
)
|
||||
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_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 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');
|
||||
|
@ -28,6 +29,7 @@ CREATE TABLE lists (
|
|||
uuid uuid NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
type list_type NOT NULL,
|
||||
optin list_optin NOT NULL DEFAULT 'single',
|
||||
tags VARCHAR(100)[],
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
@ -46,6 +47,14 @@ type subProfileData struct {
|
|||
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{
|
||||
Email: "dummy@listmonk.app",
|
||||
Name: "Dummy Subscriber",
|
||||
|
@ -73,7 +82,7 @@ func handleGetSubscriber(c echo.Context) error {
|
|||
if len(out) == 0 {
|
||||
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.")
|
||||
}
|
||||
|
||||
|
@ -123,7 +132,7 @@ func handleQuerySubscribers(c echo.Context) error {
|
|||
}
|
||||
|
||||
// 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,
|
||||
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
|
||||
}
|
||||
|
@ -157,10 +166,14 @@ func handleCreateSubscriber(c echo.Context) error {
|
|||
}
|
||||
|
||||
// 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,
|
||||
uuid.NewV4(),
|
||||
strings.ToLower(strings.TrimSpace(req.Email)),
|
||||
req.UUID,
|
||||
email,
|
||||
strings.TrimSpace(req.Name),
|
||||
req.Status,
|
||||
req.Attribs,
|
||||
|
@ -169,11 +182,13 @@ func handleCreateSubscriber(c echo.Context) error {
|
|||
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.StatusInternalServerError,
|
||||
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.
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues(fmt.Sprintf("%d", newID))
|
||||
|
@ -503,6 +518,44 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool,
|
|||
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
|
||||
// SQL query expressions coming from the frontend.
|
||||
func sanitizeSQLExp(q string) string {
|
||||
|
|
Loading…
Reference in a new issue