Skip to content

Commit

Permalink
Add a new public page for end users to subscribe to public lists.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
knadh committed Jan 31, 2021
1 parent a7b72a6 commit 2235d30
Show file tree
Hide file tree
Showing 16 changed files with 175 additions and 44 deletions.
24 changes: 13 additions & 11 deletions cmd/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
)

Expand Down
1 change: 1 addition & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
17 changes: 9 additions & 8 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion cmd/lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 41 additions & 2 deletions cmd/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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")))
}

Expand Down
3 changes: 2 additions & 1 deletion cmd/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
13 changes: 7 additions & 6 deletions cmd/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
22 changes: 16 additions & 6 deletions frontend/src/views/Forms.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>

Expand Down
8 changes: 7 additions & 1 deletion frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@
placeholder='[email protected]' />
</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">
Expand Down Expand Up @@ -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>
Expand Down
16 changes: 12 additions & 4 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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?",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion internal/migrations/v0.9.0.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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', '["[email protected]", "[email protected]"]'),
('app.lang', '"en"'),
('privacy.individual_tracking', 'false'),
Expand Down
24 changes: 22 additions & 2 deletions static/public/static/style.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
* {
box-sizing: border-box;
}

/* Flexit grid */
.container {
position: relative;
Expand Down Expand Up @@ -195,27 +199,39 @@ a:hover {
}
label {
cursor: pointer;
color: #666;
}
h1,
h2,
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;
text-decoration: none;
color: #ffff;
display: inline-block;
min-width: 150px;
font-size: 1.1em;
}
.button:hover {
background: #333;
Expand Down Expand Up @@ -255,6 +271,10 @@ section {
border-top: 1px solid #eee;
}

.form .lists {
margin-top: 45px;
}

.footer {
text-align: center;
color: #aaa;
Expand Down
Loading

0 comments on commit 2235d30

Please sign in to comment.