diff --git a/README.md b/README.md index b7e2204..2b824d5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.toml.sample b/config.toml.sample index 8ac6561..0a8d02f 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -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] diff --git a/email-templates/subscriber-data.html b/email-templates/subscriber-data.html new file mode 100644 index 0000000..3873aba --- /dev/null +++ b/email-templates/subscriber-data.html @@ -0,0 +1,9 @@ +{{ define "subscriber-data" }} +{{ template "header" . }} +

Your data

+

+ 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. +

+{{ template "footer" }} +{{ end }} diff --git a/frontend/src/Subscriber.js b/frontend/src/Subscriber.js index 7e7a2cf..5d065fb 100644 --- a/frontend/src/Subscriber.js +++ b/frontend/src/Subscriber.js @@ -7,6 +7,8 @@ import { Select, Button, Tag, + Tooltip, + Icon, Spin, Popconfirm, notification @@ -350,7 +352,7 @@ class Subscriber extends React.PureComponent {
- + {!this.state.record.id &&

Add subscriber

} {this.state.record.id && (
@@ -372,7 +374,16 @@ class Subscriber extends React.PureComponent {
)} - + + + + + + +
diff --git a/go.mod b/go.mod index 5bd5708..2ccea4f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 94fe218..2a023d7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handlers.go b/handlers.go index 0b4f3b2..0a0448d 100644 --- a/handlers.go +++ b/handlers.go @@ -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) diff --git a/main.go b/main.go index d53bdcd..9fa5a60 100644 --- a/main.go +++ b/main.go @@ -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" @@ -25,13 +26,20 @@ import ( ) type constants struct { - RootURL string `koanf:"root"` - LogoURL string `koanf:"logo_url"` - FaviconURL string `koanf:"favicon_url"` - UploadPath string `koanf:"upload_path"` - UploadURI string `koanf:"upload_uri"` - FromEmail string `koanf:"from_email"` - NotifyEmails []string `koanf:"notify_emails"` + RootURL string `koanf:"root"` + LogoURL string `koanf:"logo_url"` + FaviconURL string `koanf:"favicon_url"` + UploadPath string `koanf:"upload_path"` + 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), diff --git a/notifications.go b/notifications.go index 6273bc4..7addec1 100644 --- a/notifications.go +++ b/notifications.go @@ -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 +} diff --git a/public.go b/public.go index 85062d0..6dd83d2 100644 --- a/public.go +++ b/public.go @@ -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 = 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 { diff --git a/public/static/style.css b/public/static/style.css index 073c41e..04de28b 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -1,78 +1,273 @@ /* 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; - font-family: "Open Sans", "Helvetica Neue", sans-serif; - font-size: 16px; - line-height: 28px; - color: #111; + background: #f9f9f9; + font-family: "Open Sans", "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 28px; + color: #111; } - a { - color: #000; + color: #7f2aff; } - -h1, h2, h3, h4 { - font-weight: 400; +a:hover { + color: #111; +} +label { + cursor: pointer; +} +h1, +h2, +h3, +h4 { + font-weight: 400; +} +section { + margin-bottom: 45px; } .button { - background: #7f2aff; - padding: 10px 30px; - border-radius: 3px; - border: 0; - cursor: pointer; - text-decoration: none; - color: #ffff; - display: inline-block; + background: #7f2aff; + padding: 10px 30px; + border-radius: 3px; + border: 0; + cursor: pointer; + text-decoration: none; + color: #ffff; + display: inline-block; + min-width: 150px; +} +.button:hover { + background: #333; +} +.button.button-outline { + background: #fff; + border: 1px solid #7f2aff; + color: #7f2aff; +} +.button.button-outline:hover { + background-color: #7f2aff; + color: #fff; } - .button:hover { - background: #333; - } - .button .button-outline { - background: transparent; - border: 1px solid #ddd; - color: #444' - } - .button .button-outline:hover { - color: #111; - } .wrap { - background: #fff; - margin-top: 60px; - max-width: 600px; - padding: 45px; - box-shadow: 2px 2px 0 #f3f3f3; - border: 1px solid #eee; + background: #fff; + margin-top: 60px; + max-width: 600px; + padding: 45px; + box-shadow: 2px 2px 0 #f3f3f3; + border: 1px solid #eee; } .header { - margin-bottom: 60px; + margin-bottom: 60px; +} +.header .logo img { + width: auto; + max-width: 150px; } - .header .logo img { - width: auto; - max-width: 150px; - } .unsub-all { - margin-top: 30px; - padding-top: 30px; - border-top: 1px solid #eee; + margin-top: 30px; + padding-top: 30px; + border-top: 1px solid #eee; } .footer { - text-align: center; - color: #aaa; - font-size: 0.775em; - margin-top: 30px; - margin-bottom: 30px; + text-align: center; + color: #aaa; + font-size: 0.775em; + margin-top: 30px; + margin-bottom: 30px; +} +.footer a { + color: #aaa; + text-decoration: none; +} +.footer a:hover { + color: #111; +} + +@media screen and (max-width: 650px) { + .wrap { + margin: 0; + } + .header { + margin-bottom: 30px; + } } - .footer a { - color: #aaa; - text-decoration: none; - } - .footer a:hover { - color: #111; - } \ No newline at end of file diff --git a/public/templates/error.html b/public/templates/error.html deleted file mode 100644 index 8a849df..0000000 --- a/public/templates/error.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "error" }} - {{ template "header" .}} - -

{{ .Data.ErrorTitle }}

-
- {{ .Data.ErrorMessage }} -
- - {{ template "footer" .}} -{{ end }} diff --git a/public/templates/subscription.html b/public/templates/subscription.html new file mode 100644 index 0000000..84b787c --- /dev/null +++ b/public/templates/subscription.html @@ -0,0 +1,85 @@ +{{ define "subscription" }} +{{ template "header" .}} +
+ {{ if not .Data.Unsubscribe }} +

Unsubscribe

+

Do you wish to unsubscribe from this mailing list?

+
+
+ + +
+
+ {{ else }} +

You have been unsubscribed

+ {{ if not .Data.Blacklist }} +
+

Unsubscribe from all future communications?

+
+
+ + + +
+
+
+ {{ else }} +

You've been unsubscribed from all future communications.

+ {{ end }} + {{ end }} +
+ +{{ if or .Data.AllowExport .Data.AllowWipe }} +
+
+

Privacy and data

+ {{ if .Data.AllowExport }} +
+
+ +
+
+ +
+ A copy of your data will be e-mailed to you. +
+
+ {{ end }} + + {{ if .Data.AllowWipe }} +
+
+ +
+
+ +
+ Delete all your subscriptions and related data from our database permanently. +
+
+ {{ end }} +

+ +

+
+
+ +{{ end }} + +{{ template "footer" .}} +{{ end }} \ No newline at end of file diff --git a/public/templates/unsubscribe.html b/public/templates/unsubscribe.html deleted file mode 100644 index 906373b..0000000 --- a/public/templates/unsubscribe.html +++ /dev/null @@ -1,35 +0,0 @@ -{{ define "unsubscribe" }} - {{ template "header" .}} - - {{ if not .Data.Unsubscribe }} -

Unsubscribe

-

Do you wish to unsubscribe from this mailing list?

-
-
- - -
-
- {{ else }} -

You have been unsubscribed

- - {{ if not .Data.Blacklist }} -
-

- Unsubscribe from all future communications? -

-
-
- - - -
-
-
- {{ else }} -

You've been unsubscribed from all future communications.

- {{ end }} - {{ end }} - - {{ template "footer" .}} -{{ end }} diff --git a/queries.go b/queries.go index 397ed41..c23844e 100644 --- a/queries.go +++ b/queries.go @@ -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"` diff --git a/queries.sql b/queries.sql index 30d35a1..67409b6 100644 --- a/queries.sql +++ b/queries.sql @@ -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. diff --git a/subscribers.go b/subscribers.go index 2e8ee86..22b4ed4 100644 --- a/subscribers.go +++ b/subscribers.go @@ -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 {