From 1b279478fba51dae4dd8af585e8cc456028f71b3 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sun, 18 Oct 2020 17:33:34 +0530 Subject: [PATCH] Make individual subscriber tracking optional. A new toggle switch in Settings -> Privacy, which is off by default, allows campaign views (pixel) and link clicks to function without registering the subscriber ID against view and click events, anonymising tracking. When off, the subscriber UUIDs in view and link tracking URLs are removed, anonymising subscriber information from HTTP logs as well. --- cmd/init.go | 32 ++++++++++++++------------- cmd/public.go | 10 +++++++++ cmd/settings.go | 11 +++++----- frontend/src/views/Settings.vue | 8 +++++++ internal/manager/manager.go | 39 ++++++++++++++++++++++----------- queries.sql | 4 ++-- schema.sql | 1 + 7 files changed, 70 insertions(+), 35 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 4228f5de..59590a6f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -45,10 +45,11 @@ type constants struct { FromEmail string `koanf:"from_email"` NotifyEmails []string `koanf:"notify_emails"` Privacy struct { - AllowBlocklist bool `koanf:"allow_blocklist"` - AllowExport bool `koanf:"allow_export"` - AllowWipe bool `koanf:"allow_wipe"` - Exportable map[string]bool `koanf:"-"` + IndividualTracking bool `koanf:"individual_tracking"` + AllowBlocklist bool `koanf:"allow_blocklist"` + AllowExport bool `koanf:"allow_export"` + AllowWipe bool `koanf:"allow_wipe"` + Exportable map[string]bool `koanf:"-"` } `koanf:"privacy"` AdminUsername []byte `koanf:"admin_username"` AdminPassword []byte `koanf:"admin_password"` @@ -263,17 +264,18 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { } return manager.New(manager.Config{ - BatchSize: ko.Int("app.batch_size"), - Concurrency: ko.Int("app.concurrency"), - MessageRate: ko.Int("app.message_rate"), - MaxSendErrors: ko.Int("app.max_send_errors"), - FromEmail: cs.FromEmail, - UnsubURL: cs.UnsubURL, - OptinURL: cs.OptinURL, - LinkTrackURL: cs.LinkTrackURL, - ViewTrackURL: cs.ViewTrackURL, - MessageURL: cs.MessageURL, - UnsubHeader: ko.Bool("privacy.unsubscribe_header"), + BatchSize: ko.Int("app.batch_size"), + Concurrency: ko.Int("app.concurrency"), + MessageRate: ko.Int("app.message_rate"), + MaxSendErrors: ko.Int("app.max_send_errors"), + FromEmail: cs.FromEmail, + IndividualTracking: ko.Bool("privacy.individual_tracking"), + UnsubURL: cs.UnsubURL, + OptinURL: cs.OptinURL, + LinkTrackURL: cs.LinkTrackURL, + ViewTrackURL: cs.ViewTrackURL, + MessageURL: cs.MessageURL, + UnsubHeader: ko.Bool("privacy.unsubscribe_header"), }, newManagerDB(q), campNotifCB, lo) } diff --git a/cmd/public.go b/cmd/public.go index c04b4d21..482dfbfd 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -293,6 +293,11 @@ func handleLinkRedirect(c echo.Context) error { subUUID = c.Param("subUUID") ) + // If individual tracking is disabled, do not record the subscriber ID. + if !app.constants.Privacy.IndividualTracking { + subUUID = "" + } + var url string if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { if err != sql.ErrNoRows { @@ -318,6 +323,11 @@ func handleRegisterCampaignView(c echo.Context) error { subUUID = c.Param("subUUID") ) + // If individual tracking is disabled, do not record the subscriber ID. + if !app.constants.Privacy.IndividualTracking { + subUUID = "" + } + // Exclude dummy hits from template previews. if campUUID != dummyUUID && subUUID != dummyUUID { if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil { diff --git a/cmd/settings.go b/cmd/settings.go index 7ab26c4e..ae416dce 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -25,11 +25,12 @@ type settings struct { AppMaxSendErrors int `json:"app.max_send_errors"` AppMessageRate int `json:"app.message_rate"` - PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` - PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` - PrivacyAllowExport bool `json:"privacy.allow_export"` - PrivacyAllowWipe bool `json:"privacy.allow_wipe"` - PrivacyExportable []string `json:"privacy.exportable"` + PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` + PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` + PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` + PrivacyAllowExport bool `json:"privacy.allow_export"` + PrivacyAllowWipe bool `json:"privacy.allow_wipe"` + PrivacyExportable []string `json:"privacy.exportable"` UploadProvider string `json:"upload.provider"` UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"` diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index aa27568e..f7194bfc 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -104,6 +104,14 @@
+ + + + diff --git a/internal/manager/manager.go b/internal/manager/manager.go index aaabaea4..1d16157f 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -21,6 +21,8 @@ const ( // ContentTpl is the name of the compiled message. ContentTpl = "content" + + dummyUUID = "00000000-0000-0000-0000-000000000000" ) // DataSource represents a data backend, such as a database, @@ -86,17 +88,18 @@ type Config struct { // Number of subscribers to pull from the DB in a single iteration. BatchSize int - Concurrency int - MessageRate int - MaxSendErrors int - RequeueOnError bool - FromEmail string - LinkTrackURL string - UnsubURL string - OptinURL string - MessageURL string - ViewTrackURL string - UnsubHeader bool + Concurrency int + MessageRate int + MaxSendErrors int + RequeueOnError bool + FromEmail string + IndividualTracking bool + LinkTrackURL string + UnsubURL string + OptinURL string + MessageURL string + ViewTrackURL string + UnsubHeader bool } type msgError struct { @@ -297,11 +300,21 @@ func (m *Manager) messageWorker() { func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap { return template.FuncMap{ "TrackLink": func(url string, msg *CampaignMessage) string { - return m.trackLink(url, msg.Campaign.UUID, msg.Subscriber.UUID) + subUUID := msg.Subscriber.UUID + if !m.cfg.IndividualTracking { + subUUID = dummyUUID + } + + return m.trackLink(url, msg.Campaign.UUID, subUUID) }, "TrackView": func(msg *CampaignMessage) template.HTML { + subUUID := msg.Subscriber.UUID + if !m.cfg.IndividualTracking { + subUUID = dummyUUID + } + return template.HTML(fmt.Sprintf(``, - fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, msg.Subscriber.UUID))) + fmt.Sprintf(m.cfg.ViewTrackURL, msg.Campaign.UUID, subUUID))) }, "UnsubscribeURL": func(msg *CampaignMessage) string { return msg.unsubURL diff --git a/queries.sql b/queries.sql index a07da15c..8c156f2c 100644 --- a/queries.sql +++ b/queries.sql @@ -591,7 +591,7 @@ DELETE FROM campaigns WHERE id=$1; -- name: register-campaign-view WITH view AS ( SELECT campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM campaigns - LEFT JOIN subscribers ON (subscribers.uuid = $2) + LEFT JOIN subscribers ON (CASE WHEN $2::TEXT != '' THEN subscribers.uuid = $2::UUID ELSE FALSE END) WHERE campaigns.uuid = $1 ) INSERT INTO campaign_views (campaign_id, subscriber_id) @@ -674,7 +674,7 @@ INSERT INTO links (uuid, url) VALUES($1, $2) ON CONFLICT (url) DO UPDATE SET url WITH link AS ( SELECT url, links.id AS link_id, campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM links LEFT JOIN campaigns ON (campaigns.uuid = $2) - LEFT JOIN subscribers ON (subscribers.uuid = $3) + LEFT JOIN subscribers ON (CASE WHEN $3::TEXT != '' THEN subscribers.uuid = $3::UUID ELSE FALSE END) WHERE links.uuid = $1 ) INSERT INTO link_clicks (campaign_id, subscriber_id, link_id) diff --git a/schema.sql b/schema.sql index 7eb9a5dc..e63924f5 100644 --- a/schema.sql +++ b/schema.sql @@ -174,6 +174,7 @@ INSERT INTO settings (key, value) VALUES ('app.batch_size', '1000'), ('app.max_send_errors', '1000'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), + ('privacy.individual_tracking', 'false'), ('privacy.unsubscribe_header', 'true'), ('privacy.allow_blocklist', 'true'), ('privacy.allow_export', 'true'),