diff --git a/README.md b/README.md index a184b5b..35a3a8e 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/campaigns.go b/campaigns.go index ee9af4d..b6e1b47 100644 --- a/campaigns.go +++ b/campaigns.go @@ -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 } diff --git a/config.toml.sample b/config.toml.sample index 8ac6561..f58b0b9 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -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] diff --git a/email-templates/default.tpl b/email-templates/default.tpl index 3fd7a36..f76dc97 100644 --- a/email-templates/default.tpl +++ b/email-templates/default.tpl @@ -1,8 +1,8 @@
- - + +Don't want to receive these e-mails? Unsubscribe
-Powered by listmonk
+Don't want to receive these e-mails? Unsubscribe
+Powered by listmonk
+ 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 {Do you wish to unsubscribe from this mailing list?
+ +Do you wish to unsubscribe from this mailing list?
- - {{ else }} -- Unsubscribe from all future communications? -
- -You've been unsubscribed from all future communications.
- {{ end }} - {{ end }} - - {{ template "footer" .}} -{{ end }} diff --git a/queries.go b/queries.go index 397ed41..dace61b 100644 --- a/queries.go +++ b/queries.go @@ -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"` diff --git a/queries.sql b/queries.sql index 6d69b96..3108f00 100644 --- a/queries.sql +++ b/queries.sql @@ -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. diff --git a/subscribers.go b/subscribers.go index b447e1a..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", @@ -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 { diff --git a/utils.go b/utils.go index 4c90f11..4be23fd 100644 --- a/utils.go +++ b/utils.go @@ -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 }