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 }}
+
+
+{{ 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 {