Add data privacy export / wipe features (aimed at GDPR compliance).

- Toggle options to enable self-service data export and wipe
  options on the public unsubscription page. Subscribers can get
  a copy of all data on them e-mailed to them as JSON, or
  instantly wipe all their data.
- Refactor "unsubscribe" pages and URIs to "subscription".
- Add export icon to subscriber admin view.
This commit is contained in:
Kailash Nadh 2019-07-21 19:18:41 +05:30
parent d390bc904c
commit 3b7902802e
17 changed files with 659 additions and 125 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

@ -44,6 +44,25 @@ concurrency = 100
max_send_errors = 1000
[privacy]
# 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 +72,6 @@ password = "listmonk"
database = "listmonk"
ssl_mode = "disable"
# TQekh4quVgGc3HQ
# SMTP servers.
[smtp]

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

@ -8,7 +8,6 @@ import (
"strings"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo"
)
@ -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,8 +97,10 @@ 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("/subscription/:campUUID/:subUUID", handleSubscriptionPage)
e.POST("/subscription/:campUUID/:subUUID", handleSubscriptionPage)
e.POST("/subscription/export/:subUUID", handleSelfExportSubscriberData)
e.POST("/subscription/wipe/:subUUID", handleWipeSubscriberData)
e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect)
e.GET("/campaign/:campUUID/:subUUID/px.png", handleRegisterCampaignView)

12
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,13 @@ 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 {
AllowExport bool `koanf:"allow_export"`
AllowWipe bool `koanf:"allow_wipe"`
Exportable map[string]bool `koanf:"-"`
}
// App contains the "global" components that are
@ -183,9 +191,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 +263,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

@ -31,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
}

114
public.go
View File

@ -10,7 +10,9 @@ import (
"regexp"
"strconv"
"github.com/knadh/listmonk/messenger"
"github.com/labstack/echo"
"github.com/lib/pq"
)
// tplRenderer wraps a template.tplRenderer for echo.
@ -37,8 +39,11 @@ type publicTpl struct {
type unsubTpl struct {
publicTpl
SubUUID string
Unsubscribe bool
Blacklist bool
AllowExport bool
AllowWipe bool
}
type msgTpl struct {
@ -62,20 +67,23 @@ 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.SubUUID = subUUID
out.Blacklist = blacklist
out.Title = "Unsubscribe from mailing list"
out.AllowExport = app.Constants.Privacy.AllowExport
out.AllowWipe = app.Constants.Privacy.AllowWipe
if !regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) {
@ -105,7 +113,7 @@ func handleUnsubscribePage(c echo.Context) error {
}
}
return c.Render(http.StatusOK, "unsubscribe", out)
return c.Render(http.StatusOK, "subscription", out)
}
// handleLinkRedirect handles link UUID to real link redirection.
@ -154,6 +162,104 @@ func handleRegisterCampaignView(c echo.Context) error {
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")
)
if !regexValidUUID.MatchString(subUUID) {
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Invalid request", "",
"The subscriber ID is invalid."))
}
// 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")
)
if !regexValidUUID.MatchString(subUUID) {
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Invalid request", "",
"The subscriber ID is invalid."))
}
// 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,18 +210,20 @@ h1, h2, h3, h4 {
text-decoration: none;
color: #ffff;
display: inline-block;
min-width: 150px;
}
.button:hover {
.button:hover {
background: #333;
}
.button .button-outline {
background: transparent;
border: 1px solid #ddd;
color: #444'
}
.button .button-outline:hover {
color: #111;
}
}
.button.button-outline {
background: #fff;
border: 1px solid #7f2aff;
color: #7f2aff;
}
.button.button-outline:hover {
background-color: #7f2aff;
color: #fff;
}
.wrap {
background: #fff;
@ -51,10 +237,10 @@ h1, h2, h3, h4 {
.header {
margin-bottom: 60px;
}
.header .logo img {
.header .logo img {
width: auto;
max-width: 150px;
}
}
.unsub-all {
margin-top: 30px;
@ -69,10 +255,19 @@ h1, h2, h3, h4 {
margin-top: 30px;
margin-bottom: 30px;
}
.footer a {
.footer a {
color: #aaa;
text-decoration: none;
}
.footer a:hover {
}
.footer a:hover {
color: #111;
}
@media screen and (max-width: 650px) {
.wrap {
margin: 0;
}
.header {
margin-bottom: 30px;
}
}

View File

@ -1,10 +0,0 @@
{{ define "error" }}
{{ template "header" .}}
<h2>{{ .Data.ErrorTitle }}</h2>
<div>
{{ .Data.ErrorMessage }}
</div>
{{ template "footer" .}}
{{ end }}

View File

@ -0,0 +1,85 @@
{{ define "subscription" }}
{{ template "header" .}}
<section>
{{ 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 }}
</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

@ -26,6 +26,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,6 @@
-- 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: get-subscribers-by-emails
-- Get subscribers by emails.
SELECT * FROM subscribers WHERE email=ANY($1);
@ -149,6 +148,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",
@ -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 {