From 2235d30063f7ce40af3301205a4f857cf2d9de11 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sun, 31 Jan 2021 16:19:39 +0530 Subject: [PATCH] Add a new public page for end users to subscribe to public lists. In addition to generating HTML forms for selected public lists, the form page now shows a URL (/subscription/form) that can be publicly shared to solicit subscriptions. The page lists all public lists in the database. This page can be disabled on the Settings UI. --- cmd/admin.go | 24 ++++++----- cmd/handlers.go | 1 + cmd/init.go | 17 ++++---- cmd/lists.go | 2 +- cmd/public.go | 43 ++++++++++++++++++- cmd/queries.go | 3 +- cmd/settings.go | 13 +++--- frontend/src/views/Forms.vue | 22 +++++++--- frontend/src/views/Settings.vue | 8 +++- i18n/en.json | 16 +++++-- internal/migrations/v0.9.0.go | 3 +- queries.sql | 3 ++ schema.sql | 1 + static/public/static/style.css | 24 ++++++++++- .../public/templates/subscription-form.html | 37 ++++++++++++++++ static/public/templates/subscription.html | 2 +- 16 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 static/public/templates/subscription-form.html diff --git a/cmd/admin.go b/cmd/admin.go index a0fa4fd..289ef20 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -14,14 +14,15 @@ import ( ) type configScript struct { - RootURL string `json:"rootURL"` - FromEmail string `json:"fromEmail"` - Messengers []string `json:"messengers"` - MediaProvider string `json:"mediaProvider"` - NeedsRestart bool `json:"needsRestart"` - Update *AppUpdate `json:"update"` - Langs []i18nLang `json:"langs"` - Lang json.RawMessage `json:"lang"` + RootURL string `json:"rootURL"` + FromEmail string `json:"fromEmail"` + Messengers []string `json:"messengers"` + MediaProvider string `json:"mediaProvider"` + NeedsRestart bool `json:"needsRestart"` + Update *AppUpdate `json:"update"` + Langs []i18nLang `json:"langs"` + EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"` + Lang json.RawMessage `json:"lang"` } // handleGetConfigScript returns general configuration as a Javascript @@ -30,9 +31,10 @@ func handleGetConfigScript(c echo.Context) error { var ( app = c.Get("app").(*App) out = configScript{ - RootURL: app.constants.RootURL, - FromEmail: app.constants.FromEmail, - MediaProvider: app.constants.MediaProvider, + RootURL: app.constants.RootURL, + FromEmail: app.constants.FromEmail, + MediaProvider: app.constants.MediaProvider, + EnablePublicSubPage: app.constants.EnablePublicSubPage, } ) diff --git a/cmd/handlers.go b/cmd/handlers.go index 08ef20a..a399460 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -126,6 +126,7 @@ func registerHTTPHandlers(e *echo.Echo) { g.GET("/settings/logs", handleIndexPage) // Public subscriber facing views. + e.GET("/subscription/form", handleSubscriptionFormPage) e.POST("/subscription/form", handleSubscriptionForm) e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID")) diff --git a/cmd/init.go b/cmd/init.go index 598c75c..fab4fee 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -40,14 +40,15 @@ const ( // constants contains static, constant config values required by the app. type constants struct { - RootURL string `koanf:"root_url"` - LogoURL string `koanf:"logo_url"` - FaviconURL string `koanf:"favicon_url"` - FromEmail string `koanf:"from_email"` - NotifyEmails []string `koanf:"notify_emails"` - Lang string `koanf:"lang"` - DBBatchSize int `koanf:"batch_size"` - Privacy struct { + RootURL string `koanf:"root_url"` + LogoURL string `koanf:"logo_url"` + FaviconURL string `koanf:"favicon_url"` + FromEmail string `koanf:"from_email"` + NotifyEmails []string `koanf:"notify_emails"` + EnablePublicSubPage bool `koanf:"enable_public_subscription_page"` + Lang string `koanf:"lang"` + DBBatchSize int `koanf:"batch_size"` + Privacy struct { IndividualTracking bool `koanf:"individual_tracking"` AllowBlocklist bool `koanf:"allow_blocklist"` AllowExport bool `koanf:"allow_export"` diff --git a/cmd/lists.go b/cmd/lists.go index 19e904c..2bda82c 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -50,7 +50,7 @@ func handleGetLists(c echo.Context) error { order = sortAsc } - if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { + if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { app.log.Printf("error fetching lists: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", diff --git a/cmd/public.go b/cmd/public.go index 8062a96..fdecbb4 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -68,6 +68,11 @@ type msgTpl struct { Message string } +type subFormTpl struct { + publicTpl + Lists []models.List +} + type subForm struct { subimporter.SubReq SubListUUIDs []string `form:"l"` @@ -251,6 +256,40 @@ func handleOptinPage(c echo.Context) error { return c.Render(http.StatusOK, "optin", out) } +// handleSubscriptionFormPage handles subscription requests coming from public +// HTML subscription forms. +func handleSubscriptionFormPage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + if !app.constants.EnablePublicSubPage { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.invalidFeature"))) + } + + // Get all public lists. + var lists []models.List + if err := app.queries.GetLists.Select(&lists, models.ListTypePublic); err != nil { + app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err)) + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.errorFetchingLists"))) + } + + if len(lists) == 0 { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", + app.i18n.Ts("public.noListsAvailable"))) + } + + out := subFormTpl{} + out.Title = app.i18n.T("public.sub") + out.Lists = lists + return c.Render(http.StatusOK, "subscription-form", out) +} + // handleSubscriptionForm handles subscription requests coming from public // HTML subscription forms. func handleSubscriptionForm(c echo.Context) error { @@ -267,7 +306,7 @@ func handleSubscriptionForm(c echo.Context) error { if len(req.SubListUUIDs) == 0 { return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("globals.messages.invalidUUID"))) + app.i18n.T("public.noListsSelected"))) } // If there's no name, use the name bit from the e-mail. @@ -291,7 +330,7 @@ func handleSubscriptionForm(c echo.Context) error { } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", + makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts("public.subConfirmed"))) } diff --git a/cmd/queries.go b/cmd/queries.go index cfbe478..e6f692f 100644 --- a/cmd/queries.go +++ b/cmd/queries.go @@ -44,7 +44,8 @@ type Queries struct { UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"` CreateList *sqlx.Stmt `query:"create-list"` - GetLists string `query:"get-lists"` + QueryLists string `query:"query-lists"` + GetLists *sqlx.Stmt `query:"get-lists"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"` UpdateList *sqlx.Stmt `query:"update-list"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` diff --git a/cmd/settings.go b/cmd/settings.go index 69b9f5e..5b2af27 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -14,12 +14,13 @@ import ( ) type settings struct { - AppRootURL string `json:"app.root_url"` - AppLogoURL string `json:"app.logo_url"` - AppFaviconURL string `json:"app.favicon_url"` - AppFromEmail string `json:"app.from_email"` - AppNotifyEmails []string `json:"app.notify_emails"` - AppLang string `json:"app.lang"` + AppRootURL string `json:"app.root_url"` + AppLogoURL string `json:"app.logo_url"` + AppFaviconURL string `json:"app.favicon_url"` + AppFromEmail string `json:"app.from_email"` + AppNotifyEmails []string `json:"app.notify_emails"` + EnablePublicSubPage bool `json:"app.enable_public_subscription_page"` + AppLang string `json:"app.lang"` AppBatchSize int `json:"app.batch_size"` AppConcurrency int `json:"app.concurrency"` diff --git a/frontend/src/views/Forms.vue b/frontend/src/views/Forms.vue index e943988..9fb494f 100644 --- a/frontend/src/views/Forms.vue +++ b/frontend/src/views/Forms.vue @@ -15,6 +15,16 @@ :native-value="l.uuid">{{ l.name }} + + +

{{ $t('forms.formHTML') }}

@@ -23,23 +33,23 @@

-
<form method="post" action="{{ serverConfig.rootURL }}/subscription/form" class="listmonk-form">
+        
<form method="post" action="{{ serverConfig.rootURL }}/subscription/form" class="listmonk-form">
     <div>
         <h3>Subscribe</h3>
-        <p><input type="text" name="email" placeholder="E-mail" /></p>
-        <p><input type="text" name="name" placeholder="Name (optional)" /></p>
+        <p><input type="text" name="email" placeholder="{{ $t('subscribers.email') }}" /></p>
+        <p><input type="text" name="name" placeholder="{{ $t('public.subName') }}" /></p>
       
-        <p><input type="submit" value="Subscribe" /></p>
+
+        <p><input type="submit" value="{{ $t('public.sub') }}" /></p>
     </div>
 </form>
-

diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 13eaadc..5d59663 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -51,6 +51,12 @@ placeholder='you@yoursite.com' /> + + + +
@@ -149,7 +155,7 @@ + :message="$t('settings.privacy.allowBlocklistHelp')"> diff --git a/i18n/en.json b/i18n/en.json index f7fe4cb..56e7f43 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -93,6 +93,7 @@ "forms.formHTML": "Form HTML", "forms.formHTMLHelp": "Use the following HTML to show a subscription form on an external webpage. The form should have the email field and one or more `l` (list UUID) fields. The name field is optional.", "forms.publicLists": "Public lists", + "forms.publicSubPage": "Public subscription page", "forms.selectHelp": "Select lists to add to the form.", "forms.title": "Forms", "globals.buttons.add": "Add", @@ -237,14 +238,16 @@ "public.dataRemovedTitle": "Data removed", "public.dataSent": "Your data has been e-mailed to you as an attachment.", "public.dataSentTitle": "Data e-mailed", - "public.errorFetchingCampaign": "Error fetching e-mail message", + "public.errorFetchingCampaign": "Error fetching e-mail message.", "public.errorFetchingEmail": "E-mail message not found", "public.errorFetchingLists": "Error fetching lists. Please retry.", "public.errorProcessingRequest": "Error processing request. Please retry.", "public.errorTitle": "Error", - "public.invalidFeature": "That feature is not available", + "public.invalidFeature": "That feature is not available.", "public.invalidLink": "Invalid link", - "public.noSubInfo": "There are no subscriptions to confirm", + "public.noListsAvailable": "No lists available to subscribe.", + "public.noListsSelected": "No valid lists selected to subscribe.", + "public.noSubInfo": "There are no subscriptions to confirm.", "public.noSubTitle": "No subscriptions", "public.notFoundTitle": "Not found", "public.privacyConfirmWipe": "Are you sure you want to delete all your subscription data permanently?", @@ -253,7 +256,10 @@ "public.privacyTitle": "Privacy and data", "public.privacyWipe": "Wipe your data", "public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.", - "public.subConfirmed": "Subscribed successfully", + "public.sub": "Subscribe", + "public.subTitle": "Subscribe", + "public.subName": "Name (optional)", + "public.subConfirmed": "Subscribed successfully.", "public.subConfirmedTitle": "Confirmed", "public.subNotFound": "Subscription not found.", "public.subPrivateList": "Private list", @@ -267,6 +273,8 @@ "settings.duplicateMessengerName": "Duplicate messenger name: {name}", "settings.errorEncoding": "Error encoding settings: {error}", "settings.errorNoSMTP": "At least one SMTP block should be enabled", + "settings.general.enablePublicSubPage": "Enable public subscription page", + "settings.general.enablePublicSubPageHelp": "Show a public subscription page with all the public lists for people to subscribe.", "settings.general.adminNotifEmails": "Admin notification e-mails", "settings.general.adminNotifEmailsHelp": "Comma separated list of e-mail addresses to which admin notifications such as import updates, campaign completion, failure etc. should be sent.", "settings.general.faviconURL": "Favicon URL", diff --git a/internal/migrations/v0.9.0.go b/internal/migrations/v0.9.0.go index 8468a43..ed7445d 100644 --- a/internal/migrations/v0.9.0.go +++ b/internal/migrations/v0.9.0.go @@ -15,7 +15,8 @@ func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { ('app.lang', '"en"'), ('app.message_sliding_window', 'false'), ('app.message_sliding_window_duration', '"1h"'), - ('app.message_sliding_window_rate', '10000') + ('app.message_sliding_window_rate', '10000'), + ('app.enable_public_subscription_page', 'true') ON CONFLICT DO NOTHING; -- Add alternate (plain text) body field on campaigns. diff --git a/queries.sql b/queries.sql index d74a1d9..d63c3e4 100644 --- a/queries.sql +++ b/queries.sql @@ -310,6 +310,9 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW() -- lists -- name: get-lists +SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type END) ORDER by name DESC; + +-- name: query-lists WITH ls AS ( SELECT COUNT(*) OVER () AS total, lists.* FROM lists WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT $3 diff --git a/schema.sql b/schema.sql index a2e8e2f..5681ead 100644 --- a/schema.sql +++ b/schema.sql @@ -177,6 +177,7 @@ INSERT INTO settings (key, value) VALUES ('app.message_sliding_window', 'false'), ('app.message_sliding_window_duration', '"1h"'), ('app.message_sliding_window_rate', '10000'), + ('app.enable_public_subscription_page', 'true'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), ('app.lang', '"en"'), ('privacy.individual_tracking', 'false'), diff --git a/static/public/static/style.css b/static/public/static/style.css index 09db340..a939824 100644 --- a/static/public/static/style.css +++ b/static/public/static/style.css @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + /* Flexit grid */ .container { position: relative; @@ -195,6 +199,7 @@ a:hover { } label { cursor: pointer; + color: #666; } h1, h2, @@ -202,13 +207,23 @@ h3, h4 { font-weight: 400; } -section { +.section { margin-bottom: 45px; } +input[type="text"], input[type="email"], select { + padding: 10px 15px; + border: 1px solid #888; + border-radius: 3px; + width: 100%; +} + input:focus { + border-color: #7f2aff; + } + .button { background: #7f2aff; - padding: 10px 30px; + padding: 15px 30px; border-radius: 3px; border: 0; cursor: pointer; @@ -216,6 +231,7 @@ section { color: #ffff; display: inline-block; min-width: 150px; + font-size: 1.1em; } .button:hover { background: #333; @@ -255,6 +271,10 @@ section { border-top: 1px solid #eee; } +.form .lists { + margin-top: 45px; +} + .footer { text-align: center; color: #aaa; diff --git a/static/public/templates/subscription-form.html b/static/public/templates/subscription-form.html new file mode 100644 index 0000000..2bbe852 --- /dev/null +++ b/static/public/templates/subscription-form.html @@ -0,0 +1,37 @@ +{{ define "subscription-form" }} +{{ template "header" .}} +
+

{{ L.T "public.subTitle" }}

+ +
+
+

+ + +

+

+ + +

+
+

{{ L.T "globals.terms.lists" }}

+ {{ range $i, $l := .Data.Lists }} +
+
+ +
+
+ +
+
+ {{ end }} +
+

+ +

+
+
+
+ +{{ template "footer" .}} +{{ end }} diff --git a/static/public/templates/subscription.html b/static/public/templates/subscription.html index 32b4dba..f4e8a8e 100644 --- a/static/public/templates/subscription.html +++ b/static/public/templates/subscription.html @@ -1,6 +1,6 @@ {{ define "subscription" }} {{ template "header" .}} -
+

{{ L.T "public.unsubTitle" }}

{{ L.T "public.unsubHelp" }}