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:
parent
a7b72a6b7c
commit
2235d30063
16 changed files with 175 additions and 44 deletions
24
cmd/admin.go
24
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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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"))
|
||||
|
|
17
cmd/init.go
17
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"`
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")))
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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><form method="post" action="{{ serverConfig.rootURL }}/subscription/form" class="listmonk-form">
|
||||
<pre v-if="checked.length > 0"><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>
|
||||
<template v-for="l in publicLists"><span v-if="l.uuid in selected" :key="l.id" :set="id = l.uuid.substr(0, 5)">
|
||||
<p>
|
||||
<input id="{{ id }}" type="checkbox" name="l" value="{{ l.uuid }}" />
|
||||
<input id="{{ id }}" type="checkbox" name="l" checked value="{{ l.uuid }}" />
|
||||
<label for="{{ id }}">{{ l.name }}</label>
|
||||
</p></span></template>
|
||||
<p><input type="submit" value="Subscribe" /></p>
|
||||
|
||||
<p><input type="submit" value="{{ $t('public.sub') }}" /></p>
|
||||
</div>
|
||||
</form></pre>
|
||||
</div>
|
||||
</div><!-- columns -->
|
||||
|
||||
<p v-else></p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
16
i18n/en.json
16
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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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
static/public/templates/subscription-form.html
Normal file
37
static/public/templates/subscription-form.html
Normal 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 }}
|
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue