Selaa lähdekoodia

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.
Kailash Nadh 4 vuotta sitten
vanhempi
commit
2235d30063

+ 13 - 11
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,
 		}
 	)
 

+ 1 - 0
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"))

+ 9 - 8
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"`

+ 1 - 1
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",

+ 41 - 2
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")))
 }
 

+ 2 - 1
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"`

+ 7 - 6
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"`

+ 16 - 6
frontend/src/views/Forms.vue

@@ -15,6 +15,16 @@
               :native-value="l.uuid">{{ l.name }}</b-checkbox>
           </li>
         </ul>
+
+
+        <template v-if="serverConfig.enablePublicSubscriptionPage">
+          <hr />
+          <h4>{{ $t('forms.publicSubPage') }}</h4>
+          <p>
+            <a :href="`${serverConfig.rootURL}/subscription/form`"
+              target="_blank">{{ serverConfig.rootURL }}/subscription/form</a>
+          </p>
+        </template>
       </div>
       <div class="column">
         <h4>{{ $t('forms.formHTML') }}</h4>
@@ -23,23 +33,23 @@
         </p>
 
         <!-- eslint-disable max-len -->
-        <pre>&lt;form method=&quot;post&quot; action=&quot;{{ serverConfig.rootURL }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
+        <pre v-if="checked.length > 0">&lt;form method=&quot;post&quot; action=&quot;{{ serverConfig.rootURL }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
     &lt;div&gt;
         &lt;h3&gt;Subscribe&lt;/h3&gt;
-        &lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;E-mail&quot; /&gt;&lt;/p&gt;
-        &lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;Name (optional)&quot; /&gt;&lt;/p&gt;
+        &lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;{{ $t('subscribers.email') }}&quot; /&gt;&lt;/p&gt;
+        &lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;{{ $t('public.subName') }}&quot; /&gt;&lt;/p&gt;
       <template v-for="l in publicLists"><span v-if="l.uuid in selected" :key="l.id" :set="id = l.uuid.substr(0, 5)">
         &lt;p&gt;
-          &lt;input id=&quot;{{ id }}&quot; type=&quot;checkbox&quot; name=&quot;l&quot; value=&quot;{{ l.uuid }}&quot; /&gt;
+          &lt;input id=&quot;{{ id }}&quot; type=&quot;checkbox&quot; name=&quot;l&quot; checked value=&quot;{{ l.uuid }}&quot; /&gt;
           &lt;label for=&quot;{{ id }}&quot;&gt;{{ l.name }}&lt;/label&gt;
         &lt;/p&gt;</span></template>
-        &lt;p&gt;&lt;input type=&quot;submit&quot; value=&quot;Subscribe&quot; /&gt;&lt;/p&gt;
+
+        &lt;p&gt;&lt;input type=&quot;submit&quot; value=&quot;{{ $t('public.sub') }}&quot; /&gt;&lt;/p&gt;
     &lt;/div&gt;
 &lt;/form&gt;</pre>
       </div>
     </div><!-- columns -->
 
-    <p v-else></p>
   </section>
 </template>
 

+ 7 - 1
frontend/src/views/Settings.vue

@@ -51,6 +51,12 @@
                   placeholder='you@yoursite.com' />
               </b-field>
 
+              <b-field :label="$t('settings.general.enablePublicSubPage')"
+                :message="$t('settings.general.enablePublicSubPageHelp')">
+                <b-switch v-model="form['app.enable_public_subscription_page']"
+                    name="app.enable_public_subscription_page" />
+              </b-field>
+
               <hr />
               <b-field :label="$t('settings.general.language')" label-position="on-border">
                 <b-select v-model="form['app.lang']" name="app.lang">
@@ -149,7 +155,7 @@
               </b-field>
 
               <b-field :label="$t('settings.privacy.allowBlocklist')"
-                :message="$t('settings.privacy.allowBlocklist')">
+                :message="$t('settings.privacy.allowBlocklistHelp')">
                 <b-switch v-model="form['privacy.allow_blocklist']"
                     name="privacy.allow_blocklist" />
               </b-field>

+ 12 - 4
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",

+ 2 - 1
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.

+ 3 - 0
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

+ 1 - 0
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'),

+ 22 - 2
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;

+ 37 - 0
static/public/templates/subscription-form.html

@@ -0,0 +1,37 @@
+{{ define "subscription-form" }}
+{{ template "header" .}}
+<section>
+    <h2>{{ L.T "public.subTitle" }}</h2>
+
+    <form method="post" action="" class="form">
+        <div>
+            <p>
+                <label>{{ L.T "subscribers.email" }}</label>
+                <input name="email" required="true" type="email" placeholder="{{ L.T "subscribers.email" }}" >
+            </p>
+            <p>
+                <label>{{ L.T "public.subName" }}</label>
+                <input name="name" type="text" placeholder="{{ L.T "public.subName" }}" >
+            </p>
+            <div class="lists">
+                <h2>{{ L.T "globals.terms.lists" }}</h2>
+                {{ range $i, $l := .Data.Lists }}
+                    <div class="row">
+                        <div class="one column">
+                            <input checked="true" id="l-{{ $l.UUID}}" type="checkbox" name="l" value="{{ $l.UUID }}" >
+                        </div>
+                        <div class="eleven columns">
+                            <label for="l-{{ $l.UUID}}">{{ $l.Name }}</label>
+                        </div>
+                    </div>
+                {{ end }}
+            </div>
+            <p>
+                <button type="submit" class="button">{{ L.T "public.sub" }}</button>
+            </p>
+        </div>
+    </form>
+</section>
+
+{{ template "footer" .}}
+{{ end }}

+ 1 - 1
static/public/templates/subscription.html

@@ -1,6 +1,6 @@
 {{ define "subscription" }}
 {{ template "header" .}}
-<section>
+<section class="section">
     <h2>{{ L.T "public.unsubTitle" }}</h2>
     <p>{{ L.T "public.unsubHelp" }}</p>
     <form method="post">