Skip to content

Commit

Permalink
Add ability for subscribers to manage preferences on the unsub form.
Browse files Browse the repository at this point in the history
- Ability to change name.
- Ability to unsubscribe from individual lists.
- Toggle option to enable this in Admin Settings -> Privacy.

Closes #455.
  • Loading branch information
knadh committed Oct 29, 2022
1 parent 372a144 commit 3b00831
Show file tree
Hide file tree
Showing 38 changed files with 480 additions and 52 deletions.
10 changes: 9 additions & 1 deletion cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
g = e.Group("", middleware.BasicAuth(basicAuth))
}

e.HTTPErrorHandler = func(err error, c echo.Context) {
// Generic, non-echo error. Log it.
if _, ok := err.(*echo.HTTPError); !ok {
app.log.Println(err.Error())
}
e.DefaultHTTPErrorHandler(err, c)
}

// Admin JS app views.
// /admin/static/* file server is registered in initHTTPServer().
e.GET("/", func(c echo.Context) error {
Expand Down Expand Up @@ -163,7 +171,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
e.POST("/subscription/form", handleSubscriptionForm)
e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID")))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs),
"campUUID", "subUUID"))
e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
Expand Down
1 change: 1 addition & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type constants struct {
DBBatchSize int `koanf:"batch_size"`
Privacy struct {
IndividualTracking bool `koanf:"individual_tracking"`
AllowPreferences bool `koanf:"allow_preferences"`
AllowBlocklist bool `koanf:"allow_blocklist"`
AllowExport bool `koanf:"allow_export"`
AllowWipe bool `koanf:"allow_wipe"`
Expand Down
154 changes: 135 additions & 19 deletions cmd/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,14 @@ type publicTpl struct {

type unsubTpl struct {
publicTpl
SubUUID string
AllowBlocklist bool
AllowExport bool
AllowWipe bool
Subscriber models.Subscriber
Subscriptions []models.Subscription
SubUUID string
AllowBlocklist bool
AllowExport bool
AllowWipe bool
AllowPreferences bool
ShowManage bool
}

type optinTpl struct {
Expand Down Expand Up @@ -175,36 +179,149 @@ func handleViewCampaignMessage(c echo.Context) error {
// campaigns link to.
func handleSubscriptionPage(c echo.Context) error {
var (
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
unsub = c.Request().Method == http.MethodPost
blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
out = unsubTpl{}
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
showManage, _ = strconv.ParseBool(c.FormValue("manage"))
out = unsubTpl{}
)
out.SubUUID = subUUID
out.Title = app.i18n.T("public.unsubscribeTitle")
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
out.AllowExport = app.constants.Privacy.AllowExport
out.AllowWipe = app.constants.Privacy.AllowWipe
out.AllowPreferences = app.constants.Privacy.AllowPreferences

// Unsubscribe.
if unsub {
// Is blocklisting allowed?
if !app.constants.Privacy.AllowBlocklist {
blocklist = false
if app.constants.Privacy.AllowPreferences {
out.ShowManage = showManage
}

// Get the subscriber's lists.
subs, err := app.core.GetSubscriptions(0, subUUID, false)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
}

s, err := app.core.GetSubscriber(0, subUUID, "")
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
}
out.Subscriber = s

if s.Status == models.SubscriberStatusBlockListed {
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.blocklisted")))
}

// Filter out unrelated private lists.
if showManage {
out.Subscriptions = make([]models.Subscription, 0, len(subs))
for _, s := range subs {
if s.Type == models.ListTypePrivate {
if s.SubscriptionStatus.IsZero() {
continue
}

s.Name = app.i18n.T("public.subPrivateList")
}

out.Subscriptions = append(out.Subscriptions, s)
}
}

return c.Render(http.StatusOK, "subscription", out)
}

// handleSubscriptionPage renders the subscription management page and
// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
// campaigns link to.
func handleSubscriptionPrefs(c echo.Context) error {
var (
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")

req struct {
Name string `form:"name" json:"name"`
ListUUIDs []string `form:"l" json:"list_uuids"`
Blocklist bool `form:"blocklist" json:"blocklist"`
Manage bool `form:"manage" json:"manage"`
}
)

// Read the form.
if err := c.Bind(&req); err != nil {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidData")))
}

// Simple unsubscribe.
blocklist := app.constants.Privacy.AllowBlocklist && req.Blocklist
if !req.Manage || blocklist {
if err := app.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
}

return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo")))
}

return c.Render(http.StatusOK, "subscription", out)
// Is preference management enabled?
if !app.constants.Privacy.AllowPreferences {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature")))
}

// Manage preferences.
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" || len(req.Name) > 256 {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName")))
}

// Get the subscriber from the DB.
sub, err := app.core.GetSubscriber(0, subUUID, "")
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("globals.messages.pFound",
"name", app.i18n.T("globals.terms.subscriber"))))
}
sub.Name = req.Name

// Update name.
if _, err := app.core.UpdateSubscriber(sub.ID, sub); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
}

// Get the subscriber's lists and whatever is not sent in the request (unchecked),
// unsubscribe them.
subs, err := app.core.GetSubscriptions(0, subUUID, false)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
}
reqUUIDs := make(map[string]struct{})
for _, u := range req.ListUUIDs {
reqUUIDs[u] = struct{}{}
}

unsubUUIDs := make([]string, 0, len(req.ListUUIDs))
for _, s := range subs {
if _, ok := reqUUIDs[s.UUID]; !ok {
unsubUUIDs = append(unsubUUIDs, s.UUID)
}
}

// Unsubscribe from lists.
if err := app.core.UnsubscribeLists([]int{sub.ID}, nil, unsubUUIDs); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))

}

return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("globals.messages.done"), "", app.i18n.T("public.prefsSaved")))
}

// handleOptinPage renders the double opt-in confirmation page that subscribers
Expand Down Expand Up @@ -306,7 +423,6 @@ func handleSubscriptionForm(c echo.Context) error {
// If there's a nonce value, a bot could've filled the form.
if c.FormValue("nonce") != "" {
return echo.NewHTTPError(http.StatusBadGateway, app.i18n.T("public.invalidFeature"))

}

hasOptin, err := processSubForm(c)
Expand Down Expand Up @@ -547,7 +663,7 @@ func processSubForm(c echo.Context) (bool, error) {
return false, err
}

if _, err := app.core.UpdateSubscriber(sub.ID, sub, nil, listUUIDs, false); err != nil {
if _, err := app.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false); err != nil {
return false, err
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func handleUpdateSubscriber(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}

out, err := app.core.UpdateSubscriber(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs)
out, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true)
if err != nil {
return err
}
Expand Down Expand Up @@ -364,7 +364,7 @@ func handleManageSubscriberLists(c echo.Context) error {
case "remove":
err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs)
case "unsubscribe":
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs)
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/views/settings/privacy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
name="privacy.allow_blocklist" />
</b-field>

<b-field :label="$t('settings.privacy.allowPrefs')"
:message="$t('settings.privacy.allowPrefsHelp')">
<b-switch v-model="data['privacy.allow_preferences']"
name="privacy.allow_blocklist" />
</b-field>

<b-field :label="$t('settings.privacy.allowExport')"
:message="$t('settings.privacy.allowExportHelp')">
<b-switch v-model="data['privacy.allow_export']"
Expand Down
6 changes: 6 additions & 0 deletions i18n/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@
"menu.media": "Mèdia",
"menu.newCampaign": "Crea nova",
"menu.settings": "Configuració",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "No s'ha trobat el missatge de correu electrònic.",
"public.confirmOptinSubTitle": "Confirmació de la subscripció",
"public.confirmSub": "Confirma la subscripció",
Expand All @@ -296,11 +297,14 @@
"public.errorTitle": "Error",
"public.invalidFeature": "Aquesta funció no està disponible.",
"public.invalidLink": "Enllaç no vàlid",
"public.managePrefs": "Manage preferences",
"public.managePrefsUnsub": "Uncheck lists to unsubscribe from them.",
"public.noListsAvailable": "No hi ha llistes disponibles per subscriure's.",
"public.noListsSelected": "No s'han seleccionat llistes vàlides per subscriure's.",
"public.noSubInfo": "No hi ha subscripcions per confirmar.",
"public.noSubTitle": "No hi ha subscripcions ",
"public.notFoundTitle": "No trobat",
"public.prefsSaved": "Your preferences have been saved.",
"public.privacyConfirmWipe": "Estàs segur que vols suprimir totes les dades de la teva subscripció de manera permanent?",
"public.privacyExport": "Exporta les teves dades",
"public.privacyExportHelp": "Se t'enviarà per correu electrònic una còpia de les teves dades.",
Expand Down Expand Up @@ -445,6 +449,8 @@
"settings.privacy.allowBlocklistHelp": "Vols permetre als subscriptors donar-se de baixa de totes les llistes de correu i marcar-se com a llista bloquejada?",
"settings.privacy.allowExport": "Permet l'exportació",
"settings.privacy.allowExportHelp": "Vols permetre als subscriptors exportar les dades recollides sobre ells?",
"settings.privacy.allowPrefs": "Allow preference changes",
"settings.privacy.allowPrefsHelp": "Allow subscribers to change preferences such as their names and multiple list subscriptions.",
"settings.privacy.allowWipe": "Permet l'esborrat permanent",
"settings.privacy.allowWipeHelp": "Permet als subscriptors esborrar-se, incloses les seves subscripcions i totes les altres dades de la base de dades. Les visualitzacions de campanya i els clics als enllaços també s'eliminen mentre es mantenen les visualitzacions i els recomptes de clics (sense subscriptors associats a ells) de manera que les estadístiques i els indicadors no es veuran afectats.",
"settings.privacy.domainBlocklist": "Llista de dominis bloquejats",
Expand Down
6 changes: 6 additions & 0 deletions i18n/cs-cz.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
"menu.media": "Médium",
"menu.newCampaign": "Vytvořit nový",
"menu.settings": "Nastavení",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "E-mailová zpráva nebyla nalezena.",
"public.confirmOptinSubTitle": "Potvrdit odběr",
"public.confirmSub": "Potvrdit odběr",
Expand All @@ -297,11 +298,14 @@
"public.errorTitle": "Chyba",
"public.invalidFeature": "Tato funkce není k dispozici.",
"public.invalidLink": "Neplatný odkaz",
"public.managePrefs": "Manage preferences",
"public.managePrefsUnsub": "Uncheck lists to unsubscribe from them.",
"public.noListsAvailable": "Nejsou k dispozici žádné seznamy k odběru.",
"public.noListsSelected": "Nebyly vybrány žádné platné seznamy k odběru.",
"public.noSubInfo": "Nejsou zde žádné odběry k potvrzení.",
"public.noSubTitle": "Žádné odběry",
"public.notFoundTitle": "Nebyl nalezen",
"public.prefsSaved": "Your preferences have been saved.",
"public.privacyConfirmWipe": "Opravdu chcete trvale odstranit všechna data svých odběrů?",
"public.privacyExport": "Exportovat data",
"public.privacyExportHelp": "Kopie dat vám bude odeslána e-mailem.",
Expand Down Expand Up @@ -446,6 +450,8 @@
"settings.privacy.allowBlocklistHelp": "Povolit odběratelům zrušit odběr ze všech seznamů adresářů a označit sebe jako blokované?",
"settings.privacy.allowExport": "Umožnit export",
"settings.privacy.allowExportHelp": "Umožnit odběratelům exportovat shromážděná data?",
"settings.privacy.allowPrefs": "Allow preference changes",
"settings.privacy.allowPrefsHelp": "Allow subscribers to change preferences such as their names and multiple list subscriptions.",
"settings.privacy.allowWipe": "Umožnit vymazání",
"settings.privacy.allowWipeHelp": "Umožnit odběratelům odstranit sebe včetně svých odběrů a všech ostatních dat z databáze. Pohledy na kampaně a klepnutí na odkazy se rovněž odeberou, zatímco pohledy a počty klepnutí se zachovají (aniž by měly přidruženého odběratele), takže statistiky a analýzy nebudou ovlivněny.",
"settings.privacy.domainBlocklist": "Seznam blokovaných domén",
Expand Down
6 changes: 6 additions & 0 deletions i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
"menu.media": "Medien",
"menu.newCampaign": "Neu Anlegen",
"menu.settings": "Einstellungen",
"public.blocklisted": "Permanently unsubscribed.",
"public.campaignNotFound": "Die E-Mail wurde nicht gefunden.",
"public.confirmOptinSubTitle": "Abonnement bestätigen",
"public.confirmSub": "Abonnement bestätigen",
Expand All @@ -297,11 +298,14 @@
"public.errorTitle": "Fehler",
"public.invalidFeature": "Dieses Feature ist nicht verfügbar",
"public.invalidLink": "Ungültiger Link",
"public.managePrefs": "Manage preferences",
"public.managePrefsUnsub": "Uncheck lists to unsubscribe from them.",
"public.noListsAvailable": "Keine Listen zum Abonnieren verfügbar.",
"public.noListsSelected": "Keine Liste zum Abonnieren ausgewählt.",
"public.noSubInfo": "Es gibt keine zu bestätigenden Abonnements",
"public.noSubTitle": "Keine Abonnements",
"public.notFoundTitle": "Nicht gefunden",
"public.prefsSaved": "Your preferences have been saved.",
"public.privacyConfirmWipe": "Bist du sicher, dass du alle Abonnements und Daten dauerhaft löschen möchtest?",
"public.privacyExport": "Daten exportieren",
"public.privacyExportHelp": "Eine Kopie der gespeicherten Daten wird an deine E-Mail-Adresse versendet.",
Expand Down Expand Up @@ -446,6 +450,8 @@
"settings.privacy.allowBlocklistHelp": "Erlaube es Abonnenten ihre E-Mail-Adresse dauerhaft zu sperren.",
"settings.privacy.allowExport": "Export aktivieren",
"settings.privacy.allowExportHelp": "Erlaube Abonnenten alle ihre Daten zu exportieren?",
"settings.privacy.allowPrefs": "Allow preference changes",
"settings.privacy.allowPrefsHelp": "Allow subscribers to change preferences such as their names and multiple list subscriptions.",
"settings.privacy.allowWipe": "Löschen aktivieren",
"settings.privacy.allowWipeHelp": "Erlaube Abonnenten alle Daten, welche über sie gespeichert sind zu löschen. Dies beinhaltet auch Klicks und Anzeigen, verändert allerdings nicht die Gesamtzahl. Statistiken bleiben auch unverändert.",
"settings.privacy.domainBlocklist": "Domain-Sperrliste",
Expand Down
Loading

0 comments on commit 3b00831

Please sign in to comment.