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.
This commit is contained in:
parent
50e488fc01
commit
1b279478fb
32
cmd/init.go
32
cmd/init.go
|
@ -45,10 +45,11 @@ type constants struct {
|
||||||
FromEmail string `koanf:"from_email"`
|
FromEmail string `koanf:"from_email"`
|
||||||
NotifyEmails []string `koanf:"notify_emails"`
|
NotifyEmails []string `koanf:"notify_emails"`
|
||||||
Privacy struct {
|
Privacy struct {
|
||||||
AllowBlocklist bool `koanf:"allow_blocklist"`
|
IndividualTracking bool `koanf:"individual_tracking"`
|
||||||
AllowExport bool `koanf:"allow_export"`
|
AllowBlocklist bool `koanf:"allow_blocklist"`
|
||||||
AllowWipe bool `koanf:"allow_wipe"`
|
AllowExport bool `koanf:"allow_export"`
|
||||||
Exportable map[string]bool `koanf:"-"`
|
AllowWipe bool `koanf:"allow_wipe"`
|
||||||
|
Exportable map[string]bool `koanf:"-"`
|
||||||
} `koanf:"privacy"`
|
} `koanf:"privacy"`
|
||||||
AdminUsername []byte `koanf:"admin_username"`
|
AdminUsername []byte `koanf:"admin_username"`
|
||||||
AdminPassword []byte `koanf:"admin_password"`
|
AdminPassword []byte `koanf:"admin_password"`
|
||||||
|
@ -263,17 +264,18 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
|
||||||
}
|
}
|
||||||
|
|
||||||
return manager.New(manager.Config{
|
return manager.New(manager.Config{
|
||||||
BatchSize: ko.Int("app.batch_size"),
|
BatchSize: ko.Int("app.batch_size"),
|
||||||
Concurrency: ko.Int("app.concurrency"),
|
Concurrency: ko.Int("app.concurrency"),
|
||||||
MessageRate: ko.Int("app.message_rate"),
|
MessageRate: ko.Int("app.message_rate"),
|
||||||
MaxSendErrors: ko.Int("app.max_send_errors"),
|
MaxSendErrors: ko.Int("app.max_send_errors"),
|
||||||
FromEmail: cs.FromEmail,
|
FromEmail: cs.FromEmail,
|
||||||
UnsubURL: cs.UnsubURL,
|
IndividualTracking: ko.Bool("privacy.individual_tracking"),
|
||||||
OptinURL: cs.OptinURL,
|
UnsubURL: cs.UnsubURL,
|
||||||
LinkTrackURL: cs.LinkTrackURL,
|
OptinURL: cs.OptinURL,
|
||||||
ViewTrackURL: cs.ViewTrackURL,
|
LinkTrackURL: cs.LinkTrackURL,
|
||||||
MessageURL: cs.MessageURL,
|
ViewTrackURL: cs.ViewTrackURL,
|
||||||
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
|
MessageURL: cs.MessageURL,
|
||||||
|
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
|
||||||
}, newManagerDB(q), campNotifCB, lo)
|
}, newManagerDB(q), campNotifCB, lo)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -293,6 +293,11 @@ func handleLinkRedirect(c echo.Context) error {
|
||||||
subUUID = c.Param("subUUID")
|
subUUID = c.Param("subUUID")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// If individual tracking is disabled, do not record the subscriber ID.
|
||||||
|
if !app.constants.Privacy.IndividualTracking {
|
||||||
|
subUUID = ""
|
||||||
|
}
|
||||||
|
|
||||||
var url string
|
var url string
|
||||||
if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
|
if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
|
||||||
if err != sql.ErrNoRows {
|
if err != sql.ErrNoRows {
|
||||||
|
@ -318,6 +323,11 @@ func handleRegisterCampaignView(c echo.Context) error {
|
||||||
subUUID = c.Param("subUUID")
|
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.
|
// Exclude dummy hits from template previews.
|
||||||
if campUUID != dummyUUID && subUUID != dummyUUID {
|
if campUUID != dummyUUID && subUUID != dummyUUID {
|
||||||
if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
|
if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
|
||||||
|
|
|
@ -25,11 +25,12 @@ type settings struct {
|
||||||
AppMaxSendErrors int `json:"app.max_send_errors"`
|
AppMaxSendErrors int `json:"app.max_send_errors"`
|
||||||
AppMessageRate int `json:"app.message_rate"`
|
AppMessageRate int `json:"app.message_rate"`
|
||||||
|
|
||||||
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
|
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
|
||||||
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
|
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
|
||||||
PrivacyAllowExport bool `json:"privacy.allow_export"`
|
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
|
||||||
PrivacyAllowWipe bool `json:"privacy.allow_wipe"`
|
PrivacyAllowExport bool `json:"privacy.allow_export"`
|
||||||
PrivacyExportable []string `json:"privacy.exportable"`
|
PrivacyAllowWipe bool `json:"privacy.allow_wipe"`
|
||||||
|
PrivacyExportable []string `json:"privacy.exportable"`
|
||||||
|
|
||||||
UploadProvider string `json:"upload.provider"`
|
UploadProvider string `json:"upload.provider"`
|
||||||
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
|
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
|
||||||
|
|
|
@ -104,6 +104,14 @@
|
||||||
|
|
||||||
<b-tab-item label="Privacy">
|
<b-tab-item label="Privacy">
|
||||||
<div class="items">
|
<div class="items">
|
||||||
|
<b-field label="Individual subscriber tracking"
|
||||||
|
message="Track subscriber-level campaign views and clicks.
|
||||||
|
When disabled, view and click tracking continue without
|
||||||
|
being linked to individual subscribers.">
|
||||||
|
<b-switch v-model="form['privacy.individual_tracking']"
|
||||||
|
name="privacy.individual_tracking" />
|
||||||
|
</b-field>
|
||||||
|
|
||||||
<b-field label="Include `List-Unsubscribe` header"
|
<b-field label="Include `List-Unsubscribe` header"
|
||||||
message="Include unsubscription headers that allow e-mail clients to
|
message="Include unsubscription headers that allow e-mail clients to
|
||||||
allow users to unsubscribe in a single click.">
|
allow users to unsubscribe in a single click.">
|
||||||
|
|
|
@ -21,6 +21,8 @@ const (
|
||||||
|
|
||||||
// ContentTpl is the name of the compiled message.
|
// ContentTpl is the name of the compiled message.
|
||||||
ContentTpl = "content"
|
ContentTpl = "content"
|
||||||
|
|
||||||
|
dummyUUID = "00000000-0000-0000-0000-000000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DataSource represents a data backend, such as a database,
|
// 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.
|
// Number of subscribers to pull from the DB in a single iteration.
|
||||||
BatchSize int
|
BatchSize int
|
||||||
|
|
||||||
Concurrency int
|
Concurrency int
|
||||||
MessageRate int
|
MessageRate int
|
||||||
MaxSendErrors int
|
MaxSendErrors int
|
||||||
RequeueOnError bool
|
RequeueOnError bool
|
||||||
FromEmail string
|
FromEmail string
|
||||||
LinkTrackURL string
|
IndividualTracking bool
|
||||||
UnsubURL string
|
LinkTrackURL string
|
||||||
OptinURL string
|
UnsubURL string
|
||||||
MessageURL string
|
OptinURL string
|
||||||
ViewTrackURL string
|
MessageURL string
|
||||||
UnsubHeader bool
|
ViewTrackURL string
|
||||||
|
UnsubHeader bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type msgError struct {
|
type msgError struct {
|
||||||
|
@ -297,11 +300,21 @@ func (m *Manager) messageWorker() {
|
||||||
func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"TrackLink": func(url string, msg *CampaignMessage) string {
|
"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 {
|
"TrackView": func(msg *CampaignMessage) template.HTML {
|
||||||
|
subUUID := msg.Subscriber.UUID
|
||||||
|
if !m.cfg.IndividualTracking {
|
||||||
|
subUUID = dummyUUID
|
||||||
|
}
|
||||||
|
|
||||||
return template.HTML(fmt.Sprintf(`<img src="%s" alt="" />`,
|
return template.HTML(fmt.Sprintf(`<img src="%s" alt="" />`,
|
||||||
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 {
|
"UnsubscribeURL": func(msg *CampaignMessage) string {
|
||||||
return msg.unsubURL
|
return msg.unsubURL
|
||||||
|
|
|
@ -591,7 +591,7 @@ DELETE FROM campaigns WHERE id=$1;
|
||||||
-- name: register-campaign-view
|
-- name: register-campaign-view
|
||||||
WITH view AS (
|
WITH view AS (
|
||||||
SELECT campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM campaigns
|
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
|
WHERE campaigns.uuid = $1
|
||||||
)
|
)
|
||||||
INSERT INTO campaign_views (campaign_id, subscriber_id)
|
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 (
|
WITH link AS (
|
||||||
SELECT url, links.id AS link_id, campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM links
|
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 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
|
WHERE links.uuid = $1
|
||||||
)
|
)
|
||||||
INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
|
INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
|
||||||
|
|
|
@ -174,6 +174,7 @@ INSERT INTO settings (key, value) VALUES
|
||||||
('app.batch_size', '1000'),
|
('app.batch_size', '1000'),
|
||||||
('app.max_send_errors', '1000'),
|
('app.max_send_errors', '1000'),
|
||||||
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
|
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
|
||||||
|
('privacy.individual_tracking', 'false'),
|
||||||
('privacy.unsubscribe_header', 'true'),
|
('privacy.unsubscribe_header', 'true'),
|
||||||
('privacy.allow_blocklist', 'true'),
|
('privacy.allow_blocklist', 'true'),
|
||||||
('privacy.allow_export', 'true'),
|
('privacy.allow_export', 'true'),
|
||||||
|
|
Loading…
Reference in New Issue