diff --git a/cmd/init.go b/cmd/init.go index 4228f5d..59590a6 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 c04b4d2..482dfbf 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 7ab26c4..ae416dc 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 aa27568..f7194bf 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 aaabaea..1d16157 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 a07da15..8c156f2 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 7eb9a5d..e63924f 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'),