Add subscription forms

This commit is contained in:
Kailash Nadh 2020-03-07 20:19:22 +05:30
parent b205761fb3
commit c08ca14a5b
9 changed files with 264 additions and 56 deletions

96
frontend/src/Forms.js Normal file
View File

@ -0,0 +1,96 @@
import React from "react"
import {
Row,
Col,
Checkbox,
} from "antd"
import * as cs from "./constants"
class Forms extends React.PureComponent {
state = {
lists: [],
selected: []
}
componentDidMount() {
this.props.pageTitle("Forms")
this.props
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet, {
per_page: "all"
})
.then(() => {
this.setState({ lists: this.props.data[cs.ModelLists].results })
})
}
handleSelection(sel) {
let out = []
sel.forEach(s => {
const item = this.state.lists.find(l => {
return l.uuid === s
})
if (item) {
out.push(item)
}
})
console.log(out)
this.setState({ selected: out })
}
render() {
return (
<section className="content list-form">
<h1>Forms</h1>
<Row>
<Col span={8}>
<Checkbox.Group
className="lists"
options={this.state.lists.map(l => {
return { label: l.name, value: l.uuid }
})}
defaultValue={[]}
onChange={(sel) => this.handleSelection(sel)}
/>
</Col>
<Col span={16}>
<h1>Form HTML</h1>
<p>Use the following HTML to show a subscription form on an external webpage.</p>
<p>
The form should have the <code><strong>email</strong></code> field and one or more{" "}
<code><strong>l</strong></code> (list UUID) fields. The <code><strong>name</strong></code> field is optional.
</p>
<pre className="html">
{`<form method="post" action="${window.CONFIG.rootURL}/subscription/form" class="listmonk-subscription">
<div>
<h3>Subscribe</h3>
<p><input type="text" name="email" value="" placeholder="E-mail" /></p>
<p><input type="text" name="name" value="" placeholder="Name (optional)" /></p>`}
{(() => {
let out = [];
this.state.selected.forEach(l => {
out.push(`
<p>
<input type="checkbox" name="l" value="${l.uuid}" id="${l.uuid.substr(0,5)}" />
<label for="${l.uuid.substr(0,5)}">${l.name}</label>
</p>`);
});
return out;
})()}
{`
<p><input type="submit" value="Subscribe" /></p>
</div>
</form>
`}
</pre>
</Col>
</Row>
</section>
)
}
}
export default Forms

View File

@ -1,42 +1,43 @@
import React from "react"; import React from "react"
import { Switch, Route } from "react-router-dom"; import { Switch, Route } from "react-router-dom"
import { Link } from "react-router-dom"; import { Link } from "react-router-dom"
import { Layout, Menu, Icon } from "antd"; import { Layout, Menu, Icon } from "antd"
import logo from "./static/listmonk.svg"; import logo from "./static/listmonk.svg"
// Views. // Views.
import Dashboard from "./Dashboard"; import Dashboard from "./Dashboard"
import Lists from "./Lists"; import Lists from "./Lists"
import Subscribers from "./Subscribers"; import Forms from "./Forms"
import Subscriber from "./Subscriber"; import Subscribers from "./Subscribers"
import Templates from "./Templates"; import Subscriber from "./Subscriber"
import Import from "./Import"; import Templates from "./Templates"
import Campaigns from "./Campaigns"; import Import from "./Import"
import Campaign from "./Campaign"; import Campaigns from "./Campaigns"
import Media from "./Media"; import Campaign from "./Campaign"
import Media from "./Media"
const { Content, Footer, Sider } = Layout; const { Content, Footer, Sider } = Layout
const SubMenu = Menu.SubMenu; const SubMenu = Menu.SubMenu
const year = new Date().getUTCFullYear(); const year = new Date().getUTCFullYear()
class Base extends React.Component { class Base extends React.Component {
state = { state = {
basePath: "/" + window.location.pathname.split("/")[1], basePath: "/" + window.location.pathname.split("/")[1],
error: null, error: null,
collapsed: false collapsed: false
}; }
onCollapse = collapsed => { onCollapse = collapsed => {
this.setState({ collapsed }); this.setState({ collapsed })
}; }
componentDidMount() { componentDidMount() {
// For small screen devices collapse the menu by default. // For small screen devices collapse the menu by default.
if (window.screen.width < 768) { if (window.screen.width < 768) {
this.setState({ collapsed: true }); this.setState({ collapsed: true })
}
} }
};
render() { render() {
return ( return (
@ -65,12 +66,28 @@ class Base extends React.Component {
<span>Dashboard</span> <span>Dashboard</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
<SubMenu
key="/lists"
title={
<span>
<Icon type="bars" />
<span>Lists</span>
</span>
}
>
<Menu.Item key="/lists"> <Menu.Item key="/lists">
<Link to="/lists"> <Link to="/lists">
<Icon type="bars" /> <Icon type="bars" />
<span>Lists</span> <span>All lists</span>
</Link> </Link>
</Menu.Item> </Menu.Item>
<Menu.Item key="/lists/forms">
<Link to="/lists/forms">
<Icon type="form" />
<span>Forms</span>
</Link>
</Menu.Item>
</SubMenu>
<SubMenu <SubMenu
key="/subscribers" key="/subscribers"
title={ title={
@ -146,6 +163,14 @@ class Base extends React.Component {
<Lists {...{ ...this.props, route: props }} /> <Lists {...{ ...this.props, route: props }} />
)} )}
/> />
<Route
exact
key="/lists/forms"
path="/lists/forms"
render={props => (
<Forms {...{ ...this.props, route: props }} />
)}
/>
<Route <Route
exact exact
key="/subscribers" key="/subscribers"
@ -230,8 +255,8 @@ class Base extends React.Component {
> >
listmonk listmonk
</a>{" "} </a>{" "}
&copy; 2019 {year !== 2019 ? " - " + year : ""}. &copy; 2019 {year !== 2019 ? " - " + year : ""}. Version{" "}
Version { process.env.REACT_APP_VERSION } &mdash;{" "} {process.env.REACT_APP_VERSION} &mdash;{" "}
<a <a
href="https://listmonk.app/docs" href="https://listmonk.app/docs"
target="_blank" target="_blank"
@ -243,8 +268,8 @@ class Base extends React.Component {
</Footer> </Footer>
</Layout> </Layout>
</Layout> </Layout>
); )
} }
} }
export default Base; export default Base

View File

@ -93,7 +93,6 @@ class CreateFormDef extends React.PureComponent {
} }
modalTitle(formType, record) { modalTitle(formType, record) {
console.log(formType)
if (formType === cs.FormCreate) { if (formType === cs.FormCreate) {
return "Create a list" return "Create a list"
} }

View File

@ -48,6 +48,13 @@ hr {
padding: 30px !important; padding: 30px !important;
} }
ul.no {
list-style-type: none;
}
ul.no li {
margin-bottom: 10px;
}
/* Layout */ /* Layout */
body { body {
margin: 0; margin: 0;
@ -94,6 +101,16 @@ section.content {
} }
/* Form */ /* Form */
.list-form .html {
background: #fafafa;
padding: 30px;
max-width: 100%;
overflow-y: auto;
max-height: 600px;
}
.list-form .lists label {
display: block;
}
/* Table actions */ /* Table actions */

View File

@ -95,6 +95,7 @@ func registerHandlers(e *echo.Echo) {
e.DELETE("/api/templates/:id", handleDeleteTemplate) e.DELETE("/api/templates/:id", handleDeleteTemplate)
// Subscriber facing views. // Subscriber facing views.
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"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),

View File

@ -8,9 +8,11 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/knadh/listmonk/messenger" "github.com/knadh/listmonk/messenger"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
"github.com/knadh/listmonk/subimporter"
"github.com/labstack/echo" "github.com/labstack/echo"
"github.com/lib/pq" "github.com/lib/pq"
) )
@ -58,6 +60,11 @@ type msgTpl struct {
Message string Message string
} }
type subForm struct {
subimporter.SubReq
SubListUUIDs []string `form:"l"`
}
var ( var (
pixelPNG = drawTransparentImage(3, 14) pixelPNG = drawTransparentImage(3, 14)
) )
@ -169,6 +176,47 @@ func handleOptinPage(c echo.Context) error {
return c.Render(http.StatusOK, "optin", out) return c.Render(http.StatusOK, "optin", out)
} }
// handleOptinPage handles a double opt-in confirmation from subscribers.
func handleSubscriptionForm(c echo.Context) error {
var (
app = c.Get("app").(*App)
req subForm
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
if len(req.SubListUUIDs) == 0 {
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error", "",
`No lists to subscribe to.`))
}
// If there's no name, use the name bit from the e-mail.
req.Email = strings.ToLower(req.Email)
if req.Name == "" {
req.Name = strings.Split(req.Email, "@")[0]
}
// Validate fields.
if err := subimporter.ValidateFields(req.SubReq); err != nil {
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error", "", err.Error()))
}
// Insert the subscriber into the DB.
req.Status = models.SubscriberStatusEnabled
req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
if _, err := insertSubscriber(req.SubReq, app); err != nil {
return err
}
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Done", "", `Subscribed successfully.`))
}
// handleLinkRedirect handles link UUID to real link redirection. // handleLinkRedirect handles link UUID to real link redirection.
func handleLinkRedirect(c echo.Context) error { func handleLinkRedirect(c echo.Context) error {
var ( var (

View File

@ -54,11 +54,16 @@ WITH sub AS (
VALUES($1, $2, $3, $4, $5) VALUES($1, $2, $3, $4, $5)
returning id returning id
), ),
listIDs AS (
SELECT id FROM lists WHERE
(CASE WHEN ARRAY_LENGTH($6::INT[], 1) > 0 THEN id=ANY($6)
ELSE uuid=ANY($7::UUID[]) END)
),
subs AS ( subs AS (
INSERT INTO subscriber_lists (subscriber_id, list_id, status) INSERT INTO subscriber_lists (subscriber_id, list_id, status)
VALUES( VALUES(
(SELECT id FROM sub), (SELECT id FROM sub),
UNNEST($6::INT[]), UNNEST(ARRAY(SELECT id FROM listIDs)),
(CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END) (CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END)
) )
ON CONFLICT (subscriber_id, list_id) DO UPDATE ON CONFLICT (subscriber_id, list_id) DO UPDATE
@ -302,7 +307,7 @@ SELECT COUNT(*) OVER () AS total, lists.*, COUNT(subscriber_lists.subscriber_id)
-- name: get-lists-by-optin -- name: get-lists-by-optin
-- Can have a list of IDs or a list of UUIDs. -- Can have a list of IDs or a list of UUIDs.
SELECT * FROM lists WHERE optin=$1::list_optin AND SELECT * FROM lists WHERE (CASE WHEN $1 != '' THEN optin=$1::list_optin ELSE TRUE END) AND
(CASE WHEN $2::INT[] IS NOT NULL THEN id = ANY($2::INT[]) (CASE WHEN $2::INT[] IS NOT NULL THEN id = ANY($2::INT[])
WHEN $3::UUID[] IS NOT NULL THEN uuid = ANY($3::UUID[]) WHEN $3::UUID[] IS NOT NULL THEN uuid = ANY($3::UUID[])
END) ORDER BY name; END) ORDER BY name;

View File

@ -81,6 +81,7 @@ type Status struct {
type SubReq struct { type SubReq struct {
models.Subscriber models.Subscriber
Lists pq.Int64Array `json:"lists"` Lists pq.Int64Array `json:"lists"`
ListUUIDs pq.StringArray `json:"list_uuids"`
} }
type importStatusTpl struct { type importStatusTpl struct {
@ -562,8 +563,11 @@ func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string]bool) map
// ValidateFields validates incoming subscriber field values. // ValidateFields validates incoming subscriber field values.
func ValidateFields(s SubReq) error { func ValidateFields(s SubReq) error {
if len(s.Email) > 1000 {
return errors.New(`e-mail too long`)
}
if !govalidator.IsEmail(s.Email) { if !govalidator.IsEmail(s.Email) {
return errors.New(`invalid email "` + s.Email + `"`) return errors.New(`invalid e-mail "` + s.Email + `"`)
} }
if !govalidator.IsByteLength(s.Name, 1, stdInputMaxLen) { if !govalidator.IsByteLength(s.Name, 1, stdInputMaxLen) {
return errors.New(`invalid or empty name "` + s.Name + `"`) return errors.New(`invalid or empty name "` + s.Name + `"`)

View File

@ -161,28 +161,16 @@ func handleCreateSubscriber(c echo.Context) error {
// Get and validate fields. // Get and validate fields.
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return err return err
} else if err := subimporter.ValidateFields(req); err != nil { }
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
if err := subimporter.ValidateFields(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} }
// Insert and read ID. // Insert the subscriber into the DB.
var ( subID, err := insertSubscriber(req, app)
email = strings.ToLower(strings.TrimSpace(req.Email))
)
req.UUID = uuid.NewV4().String()
err := app.Queries.InsertSubscriber.Get(&req.ID,
req.UUID,
email,
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.Lists)
if err != nil { if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { return err
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err))
} }
// If the lists are double-optins, send confirmation e-mails. // If the lists are double-optins, send confirmation e-mails.
@ -191,7 +179,7 @@ func handleCreateSubscriber(c echo.Context) error {
// Hand over to the GET handler to return the last insertion. // Hand over to the GET handler to return the last insertion.
c.SetParamNames("id") c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", req.ID)) c.SetParamValues(fmt.Sprintf("%d", subID))
return c.JSON(http.StatusOK, handleGetSubscriber(c)) return c.JSON(http.StatusOK, handleGetSubscriber(c))
} }
@ -506,6 +494,31 @@ func handleExportSubscriberData(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json", b) return c.Blob(http.StatusOK, "application/json", b)
} }
// insertSubscriber inserts a subscriber and returns the ID.
func insertSubscriber(req subimporter.SubReq, app *App) (int, error) {
req.UUID = uuid.NewV4().String()
err := app.Queries.InsertSubscriber.Get(&req.ID,
req.UUID,
req.Email,
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.Lists,
req.ListUUIDs)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return 0, echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
}
return 0, echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err))
}
// If the lists are double-optins, send confirmation e-mails.
// Todo: This arbitrary goroutine should be moved to a centralised pool.
go sendOptinConfirmation(req.Subscriber, []int64(req.Lists), app)
return req.ID, nil
}
// exportSubscriberData collates the data of a subscriber including profile, // exportSubscriberData collates the data of a subscriber including profile,
// subscriptions, campaign_views, link_clicks (if they're enabled in the config) // subscriptions, campaign_views, link_clicks (if they're enabled in the config)
// and returns a formatted, indented JSON payload. Either takes a numeric id // and returns a formatted, indented JSON payload. Either takes a numeric id