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

@ -21,6 +21,7 @@ type configScript struct {
NeedsRestart bool `json:"needsRestart"` NeedsRestart bool `json:"needsRestart"`
Update *AppUpdate `json:"update"` Update *AppUpdate `json:"update"`
Langs []i18nLang `json:"langs"` Langs []i18nLang `json:"langs"`
EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"`
Lang json.RawMessage `json:"lang"` Lang json.RawMessage `json:"lang"`
} }
@ -33,6 +34,7 @@ func handleGetConfigScript(c echo.Context) error {
RootURL: app.constants.RootURL, RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail, FromEmail: app.constants.FromEmail,
MediaProvider: app.constants.MediaProvider, MediaProvider: app.constants.MediaProvider,
EnablePublicSubPage: app.constants.EnablePublicSubPage,
} }
) )

View File

@ -126,6 +126,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/settings/logs", handleIndexPage) g.GET("/settings/logs", handleIndexPage)
// Public subscriber facing views. // Public subscriber facing views.
e.GET("/subscription/form", handleSubscriptionFormPage)
e.POST("/subscription/form", handleSubscriptionForm) e.POST("/subscription/form", handleSubscriptionForm)
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID")) "campUUID", "subUUID"))

View File

@ -45,6 +45,7 @@ type constants struct {
FaviconURL string `koanf:"favicon_url"` FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"` FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"` NotifyEmails []string `koanf:"notify_emails"`
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
Lang string `koanf:"lang"` Lang string `koanf:"lang"`
DBBatchSize int `koanf:"batch_size"` DBBatchSize int `koanf:"batch_size"`
Privacy struct { Privacy struct {

View File

@ -50,7 +50,7 @@ func handleGetLists(c echo.Context) error {
order = sortAsc 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) app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching", app.i18n.Ts("globals.messages.errorFetching",

View File

@ -68,6 +68,11 @@ type msgTpl struct {
Message string Message string
} }
type subFormTpl struct {
publicTpl
Lists []models.List
}
type subForm struct { type subForm struct {
subimporter.SubReq subimporter.SubReq
SubListUUIDs []string `form:"l"` SubListUUIDs []string `form:"l"`
@ -251,6 +256,40 @@ func handleOptinPage(c echo.Context) error {
return c.Render(http.StatusOK, "optin", out) 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 // handleSubscriptionForm handles subscription requests coming from public
// HTML subscription forms. // HTML subscription forms.
func handleSubscriptionForm(c echo.Context) error { func handleSubscriptionForm(c echo.Context) error {
@ -267,7 +306,7 @@ func handleSubscriptionForm(c echo.Context) error {
if len(req.SubListUUIDs) == 0 { if len(req.SubListUUIDs) == 0 {
return c.Render(http.StatusBadRequest, tplMessage, return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", 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. // 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, return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", makeMsgTpl(app.i18n.T("public.subTitle"), "",
app.i18n.Ts("public.subConfirmed"))) app.i18n.Ts("public.subConfirmed")))
} }

View File

@ -44,7 +44,8 @@ type Queries struct {
UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"` UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"`
CreateList *sqlx.Stmt `query:"create-list"` 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"` GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"`
UpdateList *sqlx.Stmt `query:"update-list"` UpdateList *sqlx.Stmt `query:"update-list"`
UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` UpdateListsDate *sqlx.Stmt `query:"update-lists-date"`

View File

@ -19,6 +19,7 @@ type settings struct {
AppFaviconURL string `json:"app.favicon_url"` AppFaviconURL string `json:"app.favicon_url"`
AppFromEmail string `json:"app.from_email"` AppFromEmail string `json:"app.from_email"`
AppNotifyEmails []string `json:"app.notify_emails"` AppNotifyEmails []string `json:"app.notify_emails"`
EnablePublicSubPage bool `json:"app.enable_public_subscription_page"`
AppLang string `json:"app.lang"` AppLang string `json:"app.lang"`
AppBatchSize int `json:"app.batch_size"` AppBatchSize int `json:"app.batch_size"`

View File

@ -15,6 +15,16 @@
:native-value="l.uuid">{{ l.name }}</b-checkbox> :native-value="l.uuid">{{ l.name }}</b-checkbox>
</li> </li>
</ul> </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>
<div class="column"> <div class="column">
<h4>{{ $t('forms.formHTML') }}</h4> <h4>{{ $t('forms.formHTML') }}</h4>
@ -23,23 +33,23 @@
</p> </p>
<!-- eslint-disable max-len --> <!-- 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;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&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;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;Name (optional)&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)"> <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;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;label for=&quot;{{ id }}&quot;&gt;{{ l.name }}&lt;/label&gt;
&lt;/p&gt;</span></template> &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;/div&gt;
&lt;/form&gt;</pre> &lt;/form&gt;</pre>
</div> </div>
</div><!-- columns --> </div><!-- columns -->
<p v-else></p>
</section> </section>
</template> </template>

View File

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

View File

@ -93,6 +93,7 @@
"forms.formHTML": "Form HTML", "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.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.publicLists": "Public lists",
"forms.publicSubPage": "Public subscription page",
"forms.selectHelp": "Select lists to add to the form.", "forms.selectHelp": "Select lists to add to the form.",
"forms.title": "Forms", "forms.title": "Forms",
"globals.buttons.add": "Add", "globals.buttons.add": "Add",
@ -237,14 +238,16 @@
"public.dataRemovedTitle": "Data removed", "public.dataRemovedTitle": "Data removed",
"public.dataSent": "Your data has been e-mailed to you as an attachment.", "public.dataSent": "Your data has been e-mailed to you as an attachment.",
"public.dataSentTitle": "Data e-mailed", "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.errorFetchingEmail": "E-mail message not found",
"public.errorFetchingLists": "Error fetching lists. Please retry.", "public.errorFetchingLists": "Error fetching lists. Please retry.",
"public.errorProcessingRequest": "Error processing request. Please retry.", "public.errorProcessingRequest": "Error processing request. Please retry.",
"public.errorTitle": "Error", "public.errorTitle": "Error",
"public.invalidFeature": "That feature is not available", "public.invalidFeature": "That feature is not available.",
"public.invalidLink": "Invalid link", "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.noSubTitle": "No subscriptions",
"public.notFoundTitle": "Not found", "public.notFoundTitle": "Not found",
"public.privacyConfirmWipe": "Are you sure you want to delete all your subscription data permanently?", "public.privacyConfirmWipe": "Are you sure you want to delete all your subscription data permanently?",
@ -253,7 +256,10 @@
"public.privacyTitle": "Privacy and data", "public.privacyTitle": "Privacy and data",
"public.privacyWipe": "Wipe your data", "public.privacyWipe": "Wipe your data",
"public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.", "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.subConfirmedTitle": "Confirmed",
"public.subNotFound": "Subscription not found.", "public.subNotFound": "Subscription not found.",
"public.subPrivateList": "Private list", "public.subPrivateList": "Private list",
@ -267,6 +273,8 @@
"settings.duplicateMessengerName": "Duplicate messenger name: {name}", "settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}", "settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled", "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.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.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", "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.lang', '"en"'),
('app.message_sliding_window', 'false'), ('app.message_sliding_window', 'false'),
('app.message_sliding_window_duration', '"1h"'), ('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; ON CONFLICT DO NOTHING;
-- Add alternate (plain text) body field on campaigns. -- Add alternate (plain text) body field on campaigns.

View File

@ -310,6 +310,9 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
-- lists -- lists
-- name: get-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 ( WITH ls AS (
SELECT COUNT(*) OVER () AS total, lists.* FROM lists SELECT COUNT(*) OVER () AS total, lists.* FROM lists
WHERE ($1 = 0 OR id = $1) OFFSET $2 LIMIT $3 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', 'false'),
('app.message_sliding_window_duration', '"1h"'), ('app.message_sliding_window_duration', '"1h"'),
('app.message_sliding_window_rate', '10000'), ('app.message_sliding_window_rate', '10000'),
('app.enable_public_subscription_page', 'true'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('app.lang', '"en"'), ('app.lang', '"en"'),
('privacy.individual_tracking', 'false'), ('privacy.individual_tracking', 'false'),

View File

@ -1,3 +1,7 @@
* {
box-sizing: border-box;
}
/* Flexit grid */ /* Flexit grid */
.container { .container {
position: relative; position: relative;
@ -195,6 +199,7 @@ a:hover {
} }
label { label {
cursor: pointer; cursor: pointer;
color: #666;
} }
h1, h1,
h2, h2,
@ -202,13 +207,23 @@ h3,
h4 { h4 {
font-weight: 400; font-weight: 400;
} }
section { .section {
margin-bottom: 45px; 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 { .button {
background: #7f2aff; background: #7f2aff;
padding: 10px 30px; padding: 15px 30px;
border-radius: 3px; border-radius: 3px;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
@ -216,6 +231,7 @@ section {
color: #ffff; color: #ffff;
display: inline-block; display: inline-block;
min-width: 150px; min-width: 150px;
font-size: 1.1em;
} }
.button:hover { .button:hover {
background: #333; background: #333;
@ -255,6 +271,10 @@ section {
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
.form .lists {
margin-top: 45px;
}
.footer { .footer {
text-align: center; text-align: center;
color: #aaa; 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" }} {{ define "subscription" }}
{{ template "header" .}} {{ template "header" .}}
<section> <section class="section">
<h2>{{ L.T "public.unsubTitle" }}</h2> <h2>{{ L.T "public.unsubTitle" }}</h2>
<p>{{ L.T "public.unsubHelp" }}</p> <p>{{ L.T "public.unsubHelp" }}</p>
<form method="post"> <form method="post">