Merge branch 'privacy'

This commit is contained in:
Kailash Nadh 2019-07-21 22:12:54 +05:30
commit b63b31ec52
23 changed files with 784 additions and 224 deletions

View File

@ -49,7 +49,6 @@ Alternatively, to run a demo of listmonk, you can quickly spin up a container `d
- DB migrations
- Bounce tracking
- User auth, management, permissions
- Privacy features for subscribers (Download and wipe all tracking data)
- Ability to write raw campaign logs to a target
- Analytics views and reports
- Make Ant design UI components responsive

View File

@ -509,7 +509,7 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
fmt.Sprintf("Error rendering message: %v", err))
}
if err := app.Messenger.Push(camp.FromEmail, []string{sub.Email}, camp.Subject, m.Body); err != nil {
if err := app.Messenger.Push(camp.FromEmail, []string{sub.Email}, camp.Subject, m.Body, nil); err != nil {
return err
}

View File

@ -44,6 +44,29 @@ concurrency = 100
max_send_errors = 1000
[privacy]
# Allow subscribers to unsubscribe from all mailing lists and mark themselves
# as blacklisted?
allow_blacklist = false
# Allow subscribers to export data recorded on them?
allow_export = false
# Items to include in the data export.
# profile Subscriber's profile including custom attributes
# subscriptions Subscriber's subscription lists (private list names are masked)
# campaign_views Campaigns the subscriber has viewed and the view counts
# link_clicks Links that the subscriber has clicked and the click counts
exportable = ["profile", "subscriptions", "campaign_views", "link_clicks"]
# Allow subscribers to delete themselves from the database?
# This deletes the subscriber and all their subscriptions.
# Their association to campaign views and link clicks are also
# removed while views and click counts remain (with no subscriber
# associated to them) so that stats and analytics aren't affected.
allow_wipe = false
# Database.
[db]
host = "demo-db"
@ -53,8 +76,6 @@ password = "listmonk"
database = "listmonk"
ssl_mode = "disable"
# TQekh4quVgGc3HQ
# SMTP servers.
[smtp]

View File

@ -1,8 +1,8 @@
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<base target="_blank">
<style>
@ -56,16 +56,16 @@
}
</style>
</head>
<body style="background-color: #F0F1F3;">
<div class="gutter">&nbsp;</div>
<div class="wrap">
<body style="background-color: #F0F1F3;font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;font-size: 15px;line-height: 26px;margin: 0;color: #444;">
<div class="gutter" style="padding: 30px;">&nbsp;</div>
<div class="wrap" style="background-color: #fff;padding: 30px;max-width: 525px;margin: 0 auto;border-radius: 5px;">
{{ template "content" . }}
</div>
<div class="footer">
<p>Don't want to receive these e-mails? <a href="{{ .UnsubscribeURL }}">Unsubscribe</a></p>
<p>Powered by <a href="https://listmonk.app" target="_blank">listmonk</a></p>
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
<p>Don't want to receive these e-mails? <a href="{{ .UnsubscribeURL }}" style="color: #888;">Unsubscribe</a></p>
<p>Powered by <a href="https://listmonk.app" target="_blank" style="color: #888;">listmonk</a></p>
</div>
<div class="gutter">&nbsp;{{ TrackView }}</div>
<div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>
</body>
</html>

View File

@ -0,0 +1,9 @@
{{ define "subscriber-data" }}
{{ template "header" . }}
<h2>Your data</h2>
<p>
A copy of all data recorded on you is attached as a file in the JSON format.
It can be viewed in a text editor.
</p>
{{ template "footer" }}
{{ end }}

View File

@ -7,6 +7,8 @@ import {
Select,
Button,
Tag,
Tooltip,
Icon,
Spin,
Popconfirm,
notification
@ -350,7 +352,7 @@ class Subscriber extends React.PureComponent {
<section className="content">
<header className="header">
<Row>
<Col span={20}>
<Col span={22}>
{!this.state.record.id && <h1>Add subscriber</h1>}
{this.state.record.id && (
<div>
@ -372,7 +374,16 @@ class Subscriber extends React.PureComponent {
</div>
)}
</Col>
<Col span={2} />
<Col span={2} className="right">
<Tooltip title="Export data" placement="top">
<a
role="button"
href={"/api/subscribers/" + this.state.record.id + "/export"}
>
<Icon type="export" />
</a>
</Tooltip>
</Col>
</Row>
</header>
<div>

3
go.mod
View File

@ -7,8 +7,9 @@ require (
github.com/jmoiron/sqlx v1.2.0
github.com/jordan-wright/email v0.0.0-20181027021455-480bedc4908b
github.com/knadh/goyesql v2.0.0+incompatible
github.com/knadh/koanf v0.4.3
github.com/knadh/koanf v0.4.4
github.com/knadh/stuffbin v1.0.0
github.com/kr/pretty v0.1.0 // indirect
github.com/labstack/echo v3.3.10+incompatible
github.com/labstack/gommon v0.2.7 // indirect
github.com/lib/pq v1.0.0

8
go.sum
View File

@ -28,10 +28,17 @@ github.com/knadh/koanf v0.4.2 h1:A/bb9+eRoHHHQ57O6y66vzRCYui915CK3FdDYzNs56Q=
github.com/knadh/koanf v0.4.2/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/koanf v0.4.3 h1:aeCEnL10SVOIxnhhS3FeFtfvzC3RBphdhhrESE9qfCI=
github.com/knadh/koanf v0.4.3/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/koanf v0.4.4 h1:Pg+eR7wuJtCGHLeip31K20eJojjZ3lXE8ILQQGj2PTM=
github.com/knadh/koanf v0.4.4/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48 h1:lRb28d0+iiVwqF7Li25IJXjNRaVCQPH6n/fHwk9Qo+E=
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48/go.mod h1:afUOPBWr6bZ09aS3wbSOqXVGaO6rKcyvXYTcuG9LYpI=
github.com/knadh/stuffbin v1.0.0 h1:NQon6PTpLXies4bRFhS3VpLCf6y+jn6YVXU3i2wPQ+M=
github.com/knadh/stuffbin v1.0.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.7 h1:2qOPq/twXDrQ6ooBGrn3mrmVOC+biLlatwgIu8lbzRM=
@ -75,6 +82,7 @@ google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO50
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=

View File

@ -1,13 +1,10 @@
package main
import (
"encoding/json"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo"
)
@ -38,6 +35,8 @@ type pagination struct {
Limit int `json:"limit"`
}
var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
// registerHandlers registers HTTP handlers.
func registerHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage)
@ -45,6 +44,7 @@ func registerHandlers(e *echo.Echo) {
e.GET("/api/dashboard/stats", handleGetDashboardStats)
e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
e.POST("/api/subscribers", handleCreateSubscriber)
e.PUT("/api/subscribers/:id", handleUpdateSubscriber)
e.PUT("/api/subscribers/blacklist", handleBlacklistSubscribers)
@ -59,7 +59,6 @@ func registerHandlers(e *echo.Echo) {
e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
e.PUT("/api/subscribers/query/blacklist", handleBlacklistSubscribersByQuery)
e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
e.GET("/api/subscribers", handleQuerySubscribers)
e.GET("/api/import/subscribers", handleGetImportSubscribers)
@ -98,10 +97,18 @@ func registerHandlers(e *echo.Echo) {
e.DELETE("/api/templates/:id", handleDeleteTemplate)
// Subscriber facing views.
e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect)
e.GET("/campaign/:campUUID/:subUUID/px.png", handleRegisterCampaignView)
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
"subUUID"))
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
"subUUID"))
e.GET("/link/:linkUUID/:campUUID/:subUUID", validateUUID(handleLinkRedirect,
"linkUUID", "campUUID", "subUUID"))
e.GET("/campaign/:campUUID/:subUUID/px.png", validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID"))
// Static views.
e.GET("/lists", handleIndexPage)
@ -129,40 +136,44 @@ func handleIndexPage(c echo.Context) error {
return c.String(http.StatusOK, string(b))
}
// makeAttribsBlob takes a list of keys and values and creates
// a JSON map out of them.
func makeAttribsBlob(keys []string, vals []string) ([]byte, bool) {
attribs := make(map[string]interface{})
for i, key := range keys {
// validateUUID middleware validates the UUID string format for a given set of params.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
for _, p := range params {
if !reUUID.MatchString(c.Param(p)) {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Invalid request", "",
`One or more UUIDs in the request are invalid.`))
}
}
return next(c)
}
}
// subscriberExists middleware checks if a subscriber exists given the UUID
// param in a request.
func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
s = vals[i]
val interface{}
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
// Try to detect common JSON types.
if govalidator.IsFloat(s) {
val, _ = strconv.ParseFloat(s, 64)
} else if govalidator.IsInt(s) {
val, _ = strconv.ParseInt(s, 10, 64)
} else {
ls := strings.ToLower(s)
if ls == "true" || ls == "false" {
val, _ = strconv.ParseBool(ls)
} else {
// It's a string.
val = s
}
var exists bool
if err := app.Queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
app.Logger.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error", "",
`Error processing request. Please retry.`))
}
attribs[key] = val
if !exists {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Not found", "",
`Subscription not found.`))
}
if len(attribs) > 0 {
j, _ := json.Marshal(attribs)
return j, true
return next(c)
}
return nil, false
}
// getPagination takes form values and extracts pagination values from it.

13
main.go
View File

@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/knadh/goyesql"
"github.com/knadh/koanf"
"github.com/knadh/koanf/maps"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
@ -32,6 +33,14 @@ type constants struct {
UploadURI string `koanf:"upload_uri"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
Privacy privacyOptions `koanf:"privacy"`
}
type privacyOptions struct {
AllowBlacklist bool `koanf:"allow_blacklist"`
AllowExport bool `koanf:"allow_export"`
AllowWipe bool `koanf:"allow_wipe"`
Exportable map[string]bool `koanf:"-"`
}
// App contains the "global" components that are
@ -183,9 +192,11 @@ func main() {
var c constants
ko.Unmarshal("app", &c)
ko.Unmarshal("privacy", &c.Privacy)
c.RootURL = strings.TrimRight(c.RootURL, "/")
c.UploadURI = filepath.Clean(c.UploadURI)
c.UploadPath = filepath.Clean(c.UploadPath)
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
// Initialize the static file system into which all
// required static assets (.sql, .js files etc.) are loaded.
@ -253,7 +264,7 @@ func main() {
FromEmail: app.Constants.FromEmail,
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
UnsubscribeURL: 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),

View File

@ -238,7 +238,7 @@ func (m *Manager) SpawnWorkers() {
msg.from,
[]string{msg.to},
msg.Campaign.Subject,
msg.Body)
msg.Body, nil)
if err != nil {
m.logger.Printf("error sending message in campaign %s: %v",
msg.Campaign.Name, err)

View File

@ -66,7 +66,7 @@ func (e *emailer) Name() string {
}
// Push pushes a message to the server.
func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byte) error {
func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byte, atts []*Attachment) error {
var key string
// If there are more than one SMTP servers, send to a random
@ -77,12 +77,28 @@ func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
key = e.serverNames[0]
}
// Are there attachments?
var files []*email.Attachment
if atts != nil {
files = make([]*email.Attachment, 0, len(atts))
for _, f := range atts {
a := &email.Attachment{
Filename: f.Name,
Header: f.Header,
Content: make([]byte, len(f.Content)),
}
copy(a.Content, f.Content)
files = append(files, a)
}
}
srv := e.servers[key]
err := srv.mailer.Send(&email.Email{
From: fromAddr,
To: toAddr,
Subject: subject,
HTML: m,
Attachments: files,
}, srv.SendTimeout)
return err

View File

@ -1,10 +1,34 @@
package messenger
import "net/textproto"
// Messenger is an interface for a generic messaging backend,
// for instance, e-mail, SMS etc.
type Messenger interface {
Name() string
Push(fromAddr string, toAddr []string, subject string, message []byte) error
Push(fromAddr string, toAddr []string, subject string, message []byte, atts []*Attachment) error
Flush() error
}
// Attachment represents a file or blob attachment that can be
// sent along with a message by a Messenger.
type Attachment struct {
Name string
Header textproto.MIMEHeader
Content []byte
}
// MakeAttachmentHeader is a helper function that returns a
// textproto.MIMEHeader tailored for attachments, primarily
// email. If no encoding is given, base64 is assumed.
func MakeAttachmentHeader(filename, encoding string) textproto.MIMEHeader {
if encoding == "" {
encoding = "base64"
}
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", "attachment; filename="+filename)
h.Set("Content-Type", "application/json; name=\""+filename+"\"")
h.Set("Content-Transfer-Encoding", "base64")
return h
}

View File

@ -22,7 +22,8 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
err = app.Messenger.Push(app.Constants.FromEmail,
app.Constants.NotifyEmails,
subject,
b.Bytes())
b.Bytes(),
nil)
if err != nil {
app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
return err
@ -30,3 +31,18 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
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
var b bytes.Buffer
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
if err != nil {
return nil, err
}
return b.Bytes(), err
}

163
public.go
View File

@ -7,10 +7,11 @@ import (
"image/png"
"io"
"net/http"
"regexp"
"strconv"
"github.com/knadh/listmonk/messenger"
"github.com/labstack/echo"
"github.com/lib/pq"
)
// tplRenderer wraps a template.tplRenderer for echo.
@ -37,18 +38,19 @@ type publicTpl struct {
type unsubTpl struct {
publicTpl
Unsubscribe bool
Blacklist bool
SubUUID string
AllowBlacklist bool
AllowExport bool
AllowWipe bool
}
type errorTpl struct {
type msgTpl struct {
publicTpl
ErrorTitle string
ErrorMessage string
MessageTitle string
Message string
}
var (
regexValidUUID = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
pixelPNG = drawTransparentImage(3, 14)
)
@ -62,50 +64,42 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.
})
}
// handleUnsubscribePage unsubscribes a subscriber and renders a view.
func handleUnsubscribePage(c echo.Context) error {
// handleSubscriptionPage renders the subscription management page and
// handles unsubscriptions.
func handleSubscriptionPage(c echo.Context) error {
var (
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
out = unsubTpl{}
)
out.Unsubscribe = unsub
out.Blacklist = blacklist
out.SubUUID = subUUID
out.Title = "Unsubscribe from mailing list"
if !regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) {
return c.Render(http.StatusBadRequest, "error",
makeErrorTpl("Invalid request", "",
`The unsubscription request contains invalid IDs.
Please follow the correct link.`))
}
out.AllowBlacklist = app.Constants.Privacy.AllowBlacklist
out.AllowExport = app.Constants.Privacy.AllowExport
out.AllowWipe = app.Constants.Privacy.AllowWipe
// Unsubscribe.
if unsub {
res, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist)
if err != nil {
app.Logger.Printf("Error unsubscribing : %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
"There was an internal error while unsubscribing you.")
// Is blacklisting allowed?
if !app.Constants.Privacy.AllowBlacklist {
blacklist = false
}
if !blacklist {
num, _ := res.RowsAffected()
if num == 0 {
return c.Render(http.StatusBadRequest, "error",
makeErrorTpl("Already unsubscribed", "",
`You are not subscribed to this mailing list.
You may have already unsubscribed.`))
}
if _, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist); 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("Unsubscribed", "",
`You have been successfully unsubscribed.`))
}
return c.Render(http.StatusOK, "unsubscribe", out)
return c.Render(http.StatusOK, "subscription", out)
}
// handleLinkRedirect handles link UUID to real link redirection.
@ -116,18 +110,12 @@ func handleLinkRedirect(c echo.Context) error {
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
)
if !regexValidUUID.MatchString(linkUUID) ||
!regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) {
return c.Render(http.StatusBadRequest, "error",
makeErrorTpl("Invalid link", "", "The link you clicked is invalid."))
}
var url string
if err := app.Queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
app.Logger.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, "error",
makeErrorTpl("Error opening link", "",
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error opening link", "",
"There was an error opening the link. Please try later."))
}
@ -143,17 +131,100 @@ func handleRegisterCampaignView(c echo.Context) error {
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
)
if regexValidUUID.MatchString(campUUID) &&
regexValidUUID.MatchString(subUUID) {
if _, err := app.Queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
app.Logger.Printf("error registering campaign view: %s", err)
}
}
c.Response().Header().Set("Cache-Control", "no-cache")
return c.Blob(http.StatusOK, "image/png", pixelPNG)
}
// handleSelfExportSubscriberData pulls the subscriber's profile,
// list subscriptions, campaign views and clicks and produces
// a JSON report. This is a privacy feature and depends on the
// configuration in app.Constants.Privacy.
func handleSelfExportSubscriberData(c echo.Context) error {
var (
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
// Is export allowed?
if !app.Constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Invalid request", "",
"The feature is not available."))
}
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
data, b, err := exportSubscriberData(0, subUUID, app.Constants.Privacy.Exportable, app)
if err != nil {
app.Logger.Printf("error exporting subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error processing request", "",
"There was an error processing your request. Please try later."))
}
// 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)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error preparing data", "",
"There was an error preparing your data. Please try later."))
}
const fname = "profile.json"
if err := app.Messenger.Push(app.Constants.FromEmail,
[]string{data.Email},
"Your profile data",
msg,
[]*messenger.Attachment{
&messenger.Attachment{
Name: fname,
Content: b,
Header: messenger.MakeAttachmentHeader(fname, "base64"),
},
},
); err != nil {
app.Logger.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error e-mailing data", "",
"There was an error e-mailing your data. Please try later."))
}
return c.Render(http.StatusOK, "message",
makeMsgTpl("Data e-mailed", "",
`Your data has been e-mailed to you as an attachment.`))
}
// handleWipeSubscriberData allows a subscriber to self-delete their data. The
// profile and subscriptions are deleted, while the campaign_views and link
// clicks remain as orphan data unconnected to any subscriber.
func handleWipeSubscriberData(c echo.Context) error {
var (
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
// Is wiping allowed?
if !app.Constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Invalid request", "",
"The feature is not available."))
}
if _, err := app.Queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
app.Logger.Printf("error wiping subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error processing request", "",
"There was an error processing your request. Please try later."))
}
return c.Render(http.StatusOK, "message",
makeMsgTpl("Data removed", "",
`Your subscriptions and all associated data has been removed.`))
}
// drawTransparentImage draws a transparent PNG of given dimensions
// and returns the PNG bytes.
func drawTransparentImage(h, w int) []byte {

View File

@ -1,5 +1,179 @@
/* Flexit grid */
.container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 10px;box-sizing:border-box}.row{box-sizing:border-box;display:flex;flex:0 1 auto;flex-flow:row wrap}.columns,.column{box-sizing:border-box;flex-grow:1;flex-shrink:1;flex-basis:1;margin:10px 0 10px 4%}.column:first-child,.columns:first-child{margin-left:0}.one{max-width:4.6666666667%}.two{max-width:13.3333333333%}.three{max-width:22%}.four{max-width:30.6666666667%}.five{max-width:39.3333333333%}.six{max-width:48%}.seven{max-width:56.6666666667%}.eight{max-width:65.3333333333%}.nine{max-width:74%}.ten{max-width:82.6666666667%}.eleven{max-width:91.3333333333%}.twelve{max-width:100%;margin-left:0}.column-offset-0{margin-left:0}.column-offset-1{margin-left:8.33333333%}.column-offset-2{margin-left:16.66666667%}.column-offset-3{margin-left:25%}.column-offset-4{margin-left:33.33333333%}.column-offset-5{margin-left:41.66666667%}.column-offset-6{margin-left:50%}.column-offset-7{margin-left:58.33333333%}.column-offset-8{margin-left:66.66666667%}.column-offset-9{margin-left:75%}.column-offset-10{margin-left:83.33333333%}.column-offset-11{margin-left:91.66666667%}.between{justify-content:space-between}.evenly{justify-content:space-evenly}.around{justify-content:space-around}.center{justify-content:center;text-align:center}.start{justify-content:flex-start}.end{justify-content:flex-end}.top{align-items:flex-start}.bottom{align-items:flex-end}.middle{align-items:center}.first{order:-1}.last{order:1}.vertical{flex-flow:column wrap}.row-align-center{align-items:center}.space-right{margin-right:10px}.space-left{margin-left:10px}.space-bottom{margin-bottom:10px}.space-top{margin-top:10px}@media screen and (max-width: 768px){.container{overflow:auto}.columns,.column{min-width:100%;margin:10px 0}.column-offset-0,.column-offset-1,.column-offset-2,.column-offset-3,.column-offset-4,.column-offset-5,.column-offset-6,.column-offset-7,.column-offset-8,.column-offset-9,.column-offset-10,.column-offset-11{margin:unset}}/*# sourceMappingURL=dist/flexit.min.css.map */
.container {
position: relative;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 10px;
box-sizing: border-box;
}
.row {
box-sizing: border-box;
display: flex;
flex: 0 1 auto;
flex-flow: row wrap;
}
.columns,
.column {
box-sizing: border-box;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 1;
margin: 10px 0 10px 4%;
}
.column:first-child,
.columns:first-child {
margin-left: 0;
}
.one {
max-width: 4.6666666667%;
}
.two {
max-width: 13.3333333333%;
}
.three {
max-width: 22%;
}
.four {
max-width: 30.6666666667%;
}
.five {
max-width: 39.3333333333%;
}
.six {
max-width: 48%;
}
.seven {
max-width: 56.6666666667%;
}
.eight {
max-width: 65.3333333333%;
}
.nine {
max-width: 74%;
}
.ten {
max-width: 82.6666666667%;
}
.eleven {
max-width: 91.3333333333%;
}
.twelve {
max-width: 100%;
margin-left: 0;
}
.column-offset-0 {
margin-left: 0;
}
.column-offset-1 {
margin-left: 8.33333333%;
}
.column-offset-2 {
margin-left: 16.66666667%;
}
.column-offset-3 {
margin-left: 25%;
}
.column-offset-4 {
margin-left: 33.33333333%;
}
.column-offset-5 {
margin-left: 41.66666667%;
}
.column-offset-6 {
margin-left: 50%;
}
.column-offset-7 {
margin-left: 58.33333333%;
}
.column-offset-8 {
margin-left: 66.66666667%;
}
.column-offset-9 {
margin-left: 75%;
}
.column-offset-10 {
margin-left: 83.33333333%;
}
.column-offset-11 {
margin-left: 91.66666667%;
}
.between {
justify-content: space-between;
}
.evenly {
justify-content: space-evenly;
}
.around {
justify-content: space-around;
}
.center {
justify-content: center;
text-align: center;
}
.start {
justify-content: flex-start;
}
.end {
justify-content: flex-end;
}
.top {
align-items: flex-start;
}
.bottom {
align-items: flex-end;
}
.middle {
align-items: center;
}
.first {
order: -1;
}
.last {
order: 1;
}
.vertical {
flex-flow: column wrap;
}
.row-align-center {
align-items: center;
}
.space-right {
margin-right: 10px;
}
.space-left {
margin-left: 10px;
}
.space-bottom {
margin-bottom: 10px;
}
.space-top {
margin-top: 10px;
}
@media screen and (max-width: 768px) {
.container {
overflow: auto;
}
.columns,
.column {
min-width: 100%;
margin: 10px 0;
}
.column-offset-0,
.column-offset-1,
.column-offset-2,
.column-offset-3,
.column-offset-4,
.column-offset-5,
.column-offset-6,
.column-offset-7,
.column-offset-8,
.column-offset-9,
.column-offset-10,
.column-offset-11 {
margin: unset;
}
} /*# sourceMappingURL=dist/flexit.min.css.map */
body {
background: #f9f9f9;
@ -8,14 +182,24 @@ body {
line-height: 28px;
color: #111;
}
a {
color: #000;
color: #7f2aff;
}
h1, h2, h3, h4 {
a:hover {
color: #111;
}
label {
cursor: pointer;
}
h1,
h2,
h3,
h4 {
font-weight: 400;
}
section {
margin-bottom: 45px;
}
.button {
background: #7f2aff;
@ -26,17 +210,19 @@ h1, h2, h3, h4 {
text-decoration: none;
color: #ffff;
display: inline-block;
min-width: 150px;
}
.button:hover {
background: #333;
}
.button.button-outline {
background: transparent;
border: 1px solid #ddd;
color: #444'
background: #fff;
border: 1px solid #7f2aff;
color: #7f2aff;
}
.button.button-outline:hover {
color: #111;
background-color: #7f2aff;
color: #fff;
}
.wrap {
@ -76,3 +262,12 @@ h1, h2, h3, h4 {
.footer a:hover {
color: #111;
}
@media screen and (max-width: 650px) {
.wrap {
margin: 0;
}
.header {
margin-bottom: 30px;
}
}

View File

@ -1,9 +1,9 @@
{{ define "error" }}
{{ define "message" }}
{{ template "header" .}}
<h2>{{ .Data.ErrorTitle }}</h2>
<h2>{{ .Data.Title }}</h2>
<div>
{{ .Data.ErrorMessage }}
{{ .Data.Message }}
</div>
{{ template "footer" .}}

View File

@ -0,0 +1,74 @@
{{ define "subscription" }}
{{ template "header" .}}
<section>
<h2>Unsubscribe</h2>
<p>Do you wish to unsubscribe from this mailing list?</p>
<form method="post">
<p>
<input type="hidden" name="unsubscribe" value="true" />
</p>
{{ if .Data.AllowBlacklist }}
<p>
<input id="privacy-blacklist" type="checkbox" name="blacklist" value="true" /> <label for="privacy-blacklist">Also unsubscribe from all future e-mails.</label>
</p>
{{ end }}
<p>
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
</p>
</form>
</section>
{{ if or .Data.AllowExport .Data.AllowWipe }}
<form id="data-form" method="post" action="" onsubmit="return handleData()">
<section>
<h2>Privacy and data</h2>
{{ if .Data.AllowExport }}
<div class="row">
<div class="one columns">
<input id="privacy-export" type="radio" name="data-action" value="export" required />
</div>
<div class="ten columns">
<label for="privacy-export"><strong>Export your data</strong></label>
<br />
A copy of your data will be e-mailed to you.
</div>
</div>
{{ end }}
{{ if .Data.AllowWipe }}
<div class="row">
<div class="one columns">
<input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
</div>
<div class="ten columns">
<label for="privacy-wipe"><strong>Wipe your data</strong></label>
<br />
Delete all your subscriptions and related data from our database permanently.
</div>
</div>
{{ end }}
<p>
<input type="submit" value="Continue" class="button button-outline" />
</p>
</section>
</form>
<script>
function handleData() {
var a = document.querySelector('input[name="data-action"]:checked').value,
f = document.querySelector("#data-form");
if (a == "export") {
f.action = "/subscription/export/{{ .Data.SubUUID }}";
return true;
} else if (confirm("Are you sure you want to delete all your subscription data permanently?")) {
f.action = "/subscription/wipe/{{ .Data.SubUUID }}";
return true;
}
return false;
}
</script>
{{ end }}
{{ template "footer" .}}
{{ end }}

View File

@ -1,35 +0,0 @@
{{ define "unsubscribe" }}
{{ template "header" .}}
{{ if not .Data.Unsubscribe }}
<h2>Unsubscribe</h2>
<p>Do you wish to unsubscribe from this mailing list?</p>
<form method="post">
<div>
<input type="hidden" name="unsubscribe" value="true" />
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
</div>
</form>
{{ else }}
<h2>You have been unsubscribed</h2>
{{ if not .Data.Blacklist }}
<div class="unsub-all">
<p>
Unsubscribe from all future communications?
</p>
<form method="post">
<div>
<input type="hidden" name="unsubscribe" value="true" />
<input type="hidden" name="blacklist" value="true" />
<button type="submit" class="button button-inline" id="btn-unsuball">Unsubscribe all</button>
</div>
</form>
</div>
{{ else }}
<p>You've been unsubscribed from all future communications.</p>
{{ end }}
{{ end }}
{{ template "footer" .}}
{{ end }}

View File

@ -19,6 +19,7 @@ type Queries struct {
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
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"`
@ -26,6 +27,7 @@ type Queries struct {
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
ExportSubscriberData *sqlx.Stmt `query:"export-subscriber-data"`
// Non-prepared arbitrary subscriber queries.
QuerySubscribers string `query:"query-subscribers"`

View File

@ -3,7 +3,10 @@
-- Get a single subscriber by id or UUID.
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
-- subscribers
-- name: subscriber-exists
-- Check if a subscriber exists by id or UUID.
SELECT exists (SELECT true FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END);
-- name: get-subscribers-by-emails
-- Get subscribers by emails.
SELECT * FROM subscribers WHERE email=ANY($1);
@ -107,8 +110,8 @@ INSERT INTO subscriber_lists (subscriber_id, list_id, status)
SET status = (CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END);
-- name: delete-subscribers
-- Delete one or more subscribers.
DELETE FROM subscribers WHERE id = ANY($1);
-- Delete one or more subscribers by ID or UUID.
DELETE FROM subscribers WHERE CASE WHEN ARRAY_LENGTH($1::INT[], 1) > 0 THEN id = ANY($1) ELSE uuid = ANY($2::UUID[]) END;
-- name: blacklist-subscribers
WITH b AS (
@ -149,6 +152,46 @@ UPDATE subscriber_lists SET status = 'unsubscribed' WHERE
-- If $3 is false, unsubscribe from the campaign's lists, otherwise all lists.
CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END;
-- privacy
-- name: export-subscriber-data
WITH prof AS (
SELECT uuid, email, name, attribs, status, created_at, updated_at FROM subscribers WHERE
CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
),
subs AS (
SELECT JSON_AGG(
ROW_TO_JSON(
(SELECT l FROM (
SELECT subscriber_lists.status AS subscription_status,
(CASE WHEN lists.type = 'private' THEN 'Private list' ELSE lists.name END) as name,
lists.type, subscriber_lists.created_at
) l)
)
) AS lists FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
WHERE subscriber_lists.subscriber_id = (SELECT id FROM prof)
GROUP BY subscriber_id
),
views AS (
SELECT JSON_AGG(t) AS views FROM
(SELECT subject as campaign, COUNT(subscriber_id) as views FROM campaign_views
LEFT JOIN campaigns ON (campaigns.id = campaign_views.campaign_id)
WHERE subscriber_id = (SELECT id FROM prof)
GROUP BY campaigns.id ORDER BY id) t
),
clicks AS (
SELECT JSON_AGG(t) AS views FROM
(SELECT url, COUNT(subscriber_id) as clicks FROM link_clicks
LEFT JOIN links ON (links.id = link_clicks.link_id)
WHERE subscriber_id = (SELECT id FROM prof)
GROUP BY links.id ORDER BY id) t
)
SELECT (SELECT email FROM prof) as email,
COALESCE((SELECT JSON_AGG(t) AS profile FROM prof t), '{}') AS profile,
COALESCE((SELECT * FROM subs), '[]') AS subscriptions,
COALESCE((SELECT * FROM views), '[]') AS campaign_views,
COALESCE((SELECT * FROM clicks), '[]') AS link_clicks;
-- Partial and RAW queries used to construct arbitrary subscriber
-- queries for segmentation follow.

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
@ -35,6 +36,16 @@ type subsWrap struct {
Page int `json:"page"`
}
// subProfileData represents a subscriber's collated data in JSON
// for export.
type subProfileData struct {
Email string `db:"email" json:"-"`
Profile json.RawMessage `db:"profile" json:"profile,omitempty"`
Subscriptions json.RawMessage `db:"subscriptions" json:"subscriptions,omitempty"`
CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views,omitempty"`
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
}
var dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app",
Name: "Dummy Subscriber",
@ -327,7 +338,7 @@ func handleDeleteSubscribers(c echo.Context) error {
IDs = i
}
if _, err := app.Queries.DeleteSubscribers.Exec(IDs); err != nil {
if _, err := app.Queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting: %v", err))
}
@ -418,6 +429,77 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// handleExportSubscriberData pulls the subscriber's profile,
// list subscriptions, campaign views and clicks and produces
// a JSON report. This is a privacy feature and depends on the
// configuration in app.Constants.Privacy.
func handleExportSubscriberData(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
)
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
_, b, err := exportSubscriberData(id, "", app.Constants.Privacy.Exportable, app)
if err != nil {
app.Logger.Printf("error exporting subscriber data: %s", err)
return echo.NewHTTPError(http.StatusBadRequest,
"Error exporting subscriber data.")
}
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Content-Disposition", `attachment; filename="profile.json"`)
return c.Blob(http.StatusOK, "application/json", b)
}
// exportSubscriberData collates the data of a subscriber including profile,
// subscriptions, campaign_views, link_clicks (if they're enabled in the config)
// and returns a formatted, indented JSON payload. Either takes a numeric id
// and an empty subUUID or takes 0 and a string subUUID.
func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) {
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
var (
data subProfileData
uu interface{}
)
// UUID should be a valid value or a nil.
if subUUID != "" {
uu = subUUID
}
if err := app.Queries.ExportSubscriberData.Get(&data, id, uu); err != nil {
return data, nil, err
}
// Filter out the non-exportable items.
if _, ok := exportables["profile"]; !ok {
data.Profile = nil
}
if _, ok := exportables["subscriptions"]; !ok {
data.Subscriptions = nil
}
if _, ok := exportables["campaign_views"]; !ok {
data.CampaignViews = nil
}
if _, ok := exportables["link_clicks"]; !ok {
data.LinkClicks = nil
}
// Marshal the data into an indented payload.
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return data, nil, err
}
return data, b, nil
}
// sanitizeSQLExp does basic sanitisation on arbitrary
// SQL query expressions coming from the frontend.
func sanitizeSQLExp(q string) string {

View File

@ -246,16 +246,17 @@ func normalizeTags(tags []string) []string {
return out
}
// makeErrorTpl takes error details and returns an errorTpl
// with the error details applied to be rendered in an HTML view.
func makeErrorTpl(pageTitle, heading, desc string) errorTpl {
// makeMsgTpl takes a page title, heading, and message and returns
// a msgTpl that can be rendered as a HTML view. This is used for
// rendering aribtrary HTML views with error and success messages.
func makeMsgTpl(pageTitle, heading, msg string) msgTpl {
if heading == "" {
heading = pageTitle
}
err := errorTpl{}
err := msgTpl{}
err.Title = pageTitle
err.ErrorTitle = heading
err.ErrorMessage = desc
err.MessageTitle = heading
err.Message = msg
return err
}