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.
This commit is contained in:
Kailash Nadh 2021-01-31 16:19:39 +05:30
parent a7b72a6b7c
commit 2235d30063
16 changed files with 175 additions and 44 deletions

View File

@ -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,
}
)

View File

@ -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"))

View File

@ -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"`

View File

@ -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",

View File

@ -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")))
}

View File

@ -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"`

View File

@ -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"`

View File

@ -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>

View File

@ -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>

View File

@ -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",

View File

@ -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.

View File

@ -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

View File

@ -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'),

View File

@ -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;

View File

@ -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 }}

View File

@ -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">