diff --git a/cmd/handlers.go b/cmd/handlers.go
index d9a8e79..7e5eb01 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -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 {
@@ -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"))
diff --git a/cmd/init.go b/cmd/init.go
index 4b0caf4..43ea3d2 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -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"`
diff --git a/cmd/public.go b/cmd/public.go
index 2ce9cae..fa16c91 100644
--- a/cmd/public.go
+++ b/cmd/public.go
@@ -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 {
@@ -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
@@ -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)
@@ -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
}
diff --git a/cmd/subscribers.go b/cmd/subscribers.go
index 85afbcc..707e838 100644
--- a/cmd/subscribers.go
+++ b/cmd/subscribers.go
@@ -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
}
@@ -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"))
}
diff --git a/frontend/src/views/settings/privacy.vue b/frontend/src/views/settings/privacy.vue
index 878e567..96251a6 100644
--- a/frontend/src/views/settings/privacy.vue
+++ b/frontend/src/views/settings/privacy.vue
@@ -18,6 +18,12 @@
name="privacy.allow_blocklist" />
+
+
+
+
0 {
@@ -325,14 +320,55 @@ func (c *Core) UpdateSubscriber(id int, sub models.Subscriber, listIDs []int, li
strings.TrimSpace(sub.Name),
sub.Status,
json.RawMessage(attribs),
- pq.Array(listIDs),
- pq.Array(listUUIDs),
- subStatus)
+ )
if err != nil {
c.log.Printf("error updating subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
- c.i18n.Ts("globals.messages.errorUpdating",
- "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
+ c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
+ }
+
+ out, err := c.GetSubscriber(sub.ID, "", sub.Email)
+ if err != nil {
+ return models.Subscriber{}, err
+ }
+
+ return out, nil
+}
+
+// UpdateSubscriberWithLists updates a subscriber's properties.
+// If deleteLists is set to true, all existing subscriptions are deleted and only
+// the ones provided are added or retained.
+func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, deleteLists bool) (models.Subscriber, error) {
+ subStatus := models.SubscriptionStatusUnconfirmed
+ if preconfirm {
+ subStatus = models.SubscriptionStatusConfirmed
+ }
+
+ // Format raw JSON attributes.
+ attribs := []byte("{}")
+ if len(sub.Attribs) > 0 {
+ if b, err := json.Marshal(sub.Attribs); err != nil {
+ return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorUpdating",
+ "name", "{globals.terms.subscriber}", "error", err.Error()))
+ } else {
+ attribs = b
+ }
+ }
+
+ _, err := c.q.UpdateSubscriberWithLists.Exec(id,
+ sub.Email,
+ strings.TrimSpace(sub.Name),
+ sub.Status,
+ json.RawMessage(attribs),
+ pq.Array(listIDs),
+ pq.Array(listUUIDs),
+ subStatus,
+ deleteLists)
+ if err != nil {
+ c.log.Printf("error updating subscriber: %v", err)
+ return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
diff --git a/internal/core/subscriptions.go b/internal/core/subscriptions.go
index 35cbb22..95046af 100644
--- a/internal/core/subscriptions.go
+++ b/internal/core/subscriptions.go
@@ -4,10 +4,24 @@ import (
"net/http"
"time"
+ "github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
+// GetSubscriptions retrieves the subscriptions for a subscriber.
+func (c *Core) GetSubscriptions(subID int, subUUID string, allLists bool) ([]models.Subscription, error) {
+ var out []models.Subscription
+ err := c.q.GetSubscriptions.Select(&out, subID, subUUID, allLists)
+ if err != nil {
+ c.log.Printf("error getting subscriptions: %v", err)
+ return nil, echo.NewHTTPError(http.StatusInternalServerError,
+ c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
+ }
+
+ return out, err
+}
+
// AddSubscriptions adds list subscriptions to subscribers.
func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error {
if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil {
@@ -66,8 +80,8 @@ func (c *Core) DeleteSubscriptionsByQuery(query string, sourceListIDs, targetLis
}
// UnsubscribeLists sets list subscriptions to 'unsubscribed'.
-func (c *Core) UnsubscribeLists(subIDs, listIDs []int) error {
- if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil {
+func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []string) error {
+ if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs), pq.StringArray(listUUIDs)); err != nil {
c.log.Printf("error unsubscribing from lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
diff --git a/internal/manager/manager.go b/internal/manager/manager.go
index db13243..e819be9 100644
--- a/internal/manager/manager.go
+++ b/internal/manager/manager.go
@@ -451,6 +451,9 @@ func (m *Manager) TemplateFuncs(c *models.Campaign) template.FuncMap {
"UnsubscribeURL": func(msg *CampaignMessage) string {
return msg.unsubURL
},
+ "ManageURL": func(msg *CampaignMessage) string {
+ return msg.unsubURL + "?manage=true"
+ },
"OptinURL": func(msg *CampaignMessage) string {
// Add list IDs.
// TODO: Show private lists list on optin e-mail
diff --git a/internal/migrations/v2.3.0.go b/internal/migrations/v2.3.0.go
index 9cde73b..56d6261 100644
--- a/internal/migrations/v2.3.0.go
+++ b/internal/migrations/v2.3.0.go
@@ -12,5 +12,14 @@ func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err
}
+ // Insert appearance related settings.
+ if _, err := db.Exec(`
+ INSERT INTO settings (key, value) VALUES
+ ('privacy.allow_preferences', 'false')
+ ON CONFLICT DO NOTHING;
+ `); err != nil {
+ return err
+ }
+
return nil
}
diff --git a/models/models.go b/models/models.go
index fc40b59..30e6ced 100644
--- a/models/models.go
+++ b/models/models.go
@@ -116,7 +116,7 @@ var regTplFuncs = []regTplFunc{
},
{
- regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|OptinURL|MessageURL)(\s+)?}}`),
+ regExp: regexp.MustCompile(`{{(\s+)?(TrackView|UnsubscribeURL|ManageURL|OptinURL|MessageURL)(\s+)?}}`),
replace: `{{ $2 . }}`,
},
}
@@ -169,6 +169,13 @@ type subLists struct {
Lists types.JSONText `db:"lists"`
}
+// Subscription represents a list attached to a subscriber.
+type Subscription struct {
+ List
+ SubscriptionStatus null.String `db:"subscription_status" json:"subscription_status"`
+ SubscriptionCreatedAt null.String `db:"subscription_created_at" json:"subscription_created_at"`
+}
+
// SubscriberExportProfile represents a subscriber's collated data in JSON for export.
type SubscriberExportProfile struct {
Email string `db:"email" json:"-"`
diff --git a/models/queries.go b/models/queries.go
index bf88db4..8521af8 100644
--- a/models/queries.go
+++ b/models/queries.go
@@ -20,8 +20,10 @@ type Queries struct {
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
+ GetSubscriptions *sqlx.Stmt `query:"get-subscriptions"`
GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"`
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
+ UpdateSubscriberWithLists *sqlx.Stmt `query:"update-subscriber-with-lists"`
BlocklistSubscribers *sqlx.Stmt `query:"blocklist-subscribers"`
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"`
diff --git a/models/settings.go b/models/settings.go
index aed4996..be75961 100644
--- a/models/settings.go
+++ b/models/settings.go
@@ -24,6 +24,7 @@ type Settings struct {
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
+ PrivacyAllowPreferences bool `json:"privacy.allow_preferences"`
PrivacyAllowExport bool `json:"privacy.allow_export"`
PrivacyAllowWipe bool `json:"privacy.allow_wipe"`
PrivacyExportable []string `json:"privacy.exportable"`
diff --git a/queries.sql b/queries.sql
index c4b8e1a..f0e4edd 100644
--- a/queries.sql
+++ b/queries.sql
@@ -25,7 +25,7 @@ SELECT * FROM lists
WHEN CARDINALITY($4::UUID[]) > 0 THEN uuid = ANY($4::UUID[])
ELSE TRUE
END)
- AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status END)
+ AND (CASE WHEN $5 != '' THEN subscriber_lists.status = $5::subscription_status ELSE TRUE END)
AND (CASE WHEN $6 != '' THEN lists.optin = $6::list_optin ELSE TRUE END)
ORDER BY id;
@@ -51,6 +51,19 @@ SELECT id as subscriber_id,
LEFT JOIN subs AS s ON (s.subscriber_id = id)
ORDER BY ARRAY_POSITION($1, id);
+-- name: get-subscriptions
+-- Retrieves all lists a subscriber is attached to.
+-- if $3 is set to true, all lists are fetched including the subscriber's subscriptions.
+-- subscription_status, and subscription_created_at are null in that case.
+WITH sub AS (
+ SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
+)
+SELECT lists.*, subscriber_lists.status as subscription_status, subscriber_lists.created_at as subscription_created_at
+ FROM lists LEFT JOIN subscriber_lists
+ ON (subscriber_lists.list_id = lists.id AND subscriber_lists.subscriber_id = (SELECT id FROM sub))
+ WHERE CASE WHEN $3 = TRUE THEN TRUE ELSE subscriber_lists.status IS NOT NULL END
+ ORDER BY subscriber_lists.status;
+
-- name: insert-subscriber
WITH sub AS (
INSERT INTO subscribers (uuid, email, name, status, attribs)
@@ -115,6 +128,15 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
WHERE subscriber_id = (SELECT id FROM sub);
-- name: update-subscriber
+UPDATE subscribers SET
+ email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
+ name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
+ status=(CASE WHEN $4 != '' THEN $4::subscriber_status ELSE status END),
+ attribs=(CASE WHEN $5 != '' THEN $5::JSONB ELSE attribs END),
+ updated_at=NOW()
+WHERE id = $1;
+
+-- name: update-subscriber-with-lists
-- Updates a subscriber's data, and given a list of list_ids, inserts subscriptions
-- for them while deleting existing subscriptions not in the list.
WITH s AS (
@@ -126,13 +148,13 @@ WITH s AS (
updated_at=NOW()
WHERE id = $1 RETURNING id
),
-d AS (
- DELETE FROM subscriber_lists WHERE subscriber_id = $1 AND list_id != ALL($6)
-),
listIDs AS (
SELECT id FROM lists WHERE
(CASE WHEN CARDINALITY($6::INT[]) > 0 THEN id=ANY($6)
ELSE uuid=ANY($7::UUID[]) END)
+),
+d AS (
+ DELETE FROM subscriber_lists WHERE $9 = TRUE AND subscriber_id = $1 AND list_id != ALL(SELECT id FROM listIDs)
)
INSERT INTO subscriber_lists (subscriber_id, list_id, status)
VALUES(
@@ -182,8 +204,14 @@ UPDATE subscriber_lists SET status='confirmed', updated_at=NOW()
WHERE subscriber_id = (SELECT id FROM subID) AND list_id = ANY(SELECT id FROM listIDs);
-- name: unsubscribe-subscribers-from-lists
+WITH listIDs AS (
+ SELECT ARRAY(
+ SELECT id FROM lists WHERE
+ (CASE WHEN CARDINALITY($2::INT[]) > 0 THEN id=ANY($2) ELSE uuid=ANY($3::UUID[]) END)
+ ) id
+)
UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
- WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST($2::INT[]) b);
+ WHERE (subscriber_id, list_id) = ANY(SELECT a, b FROM UNNEST($1::INT[]) a, UNNEST((SELECT id FROM listIDs)) b);
-- name: unsubscribe-by-campaign
-- Unsubscribes a subscriber given a campaign UUID (from all the lists in the campaign) and the subscriber UUID.
diff --git a/schema.sql b/schema.sql
index fbde897..d16e7e0 100644
--- a/schema.sql
+++ b/schema.sql
@@ -197,6 +197,7 @@ INSERT INTO settings (key, value) VALUES
('privacy.allow_blocklist', 'true'),
('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'),
+ ('privacy.allow_preferences', 'true'),
('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'),
('privacy.domain_blocklist', '[]'),
('upload.provider', '"filesystem"'),
diff --git a/static/public/static/style.css b/static/public/static/style.css
index 0f28b91..7256ca4 100644
--- a/static/public/static/style.css
+++ b/static/public/static/style.css
@@ -50,6 +50,10 @@ input:focus::placeholder {
color: transparent;
}
+input[disabled] {
+ opacity: 0.5;
+}
+
.center {
text-align: center;
}
@@ -111,8 +115,7 @@ input:focus::placeholder {
.row {
margin-bottom: 20px;
}
-.form .lists {
- margin-top: 45px;
+.lists {
list-style-type: none;
padding: 0;
}
diff --git a/static/public/templates/subscription-form.html b/static/public/templates/subscription-form.html
index d49695f..152425f 100644
--- a/static/public/templates/subscription-form.html
+++ b/static/public/templates/subscription-form.html
@@ -15,6 +15,7 @@
+