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

View File

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

View File

@ -48,6 +48,13 @@ hr {
padding: 30px !important;
}
ul.no {
list-style-type: none;
}
ul.no li {
margin-bottom: 10px;
}
/* Layout */
body {
margin: 0;
@ -94,6 +101,16 @@ section.content {
}
/* 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 */

View File

@ -95,6 +95,7 @@ func registerHandlers(e *echo.Echo) {
e.DELETE("/api/templates/:id", handleDeleteTemplate)
// Subscriber facing views.
e.POST("/subscription/form", handleSubscriptionForm)
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),

View File

@ -8,9 +8,11 @@ import (
"io"
"net/http"
"strconv"
"strings"
"github.com/knadh/listmonk/messenger"
"github.com/knadh/listmonk/models"
"github.com/knadh/listmonk/subimporter"
"github.com/labstack/echo"
"github.com/lib/pq"
)
@ -58,6 +60,11 @@ type msgTpl struct {
Message string
}
type subForm struct {
subimporter.SubReq
SubListUUIDs []string `form:"l"`
}
var (
pixelPNG = drawTransparentImage(3, 14)
)
@ -169,6 +176,47 @@ func handleOptinPage(c echo.Context) error {
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.
func handleLinkRedirect(c echo.Context) error {
var (

View File

@ -54,11 +54,16 @@ WITH sub AS (
VALUES($1, $2, $3, $4, $5)
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 (
INSERT INTO subscriber_lists (subscriber_id, list_id, status)
VALUES(
(SELECT id FROM sub),
UNNEST($6::INT[]),
UNNEST(ARRAY(SELECT id FROM listIDs)),
(CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END)
)
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
-- 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[])
WHEN $3::UUID[] IS NOT NULL THEN uuid = ANY($3::UUID[])
END) ORDER BY name;

View File

@ -80,7 +80,8 @@ type Status struct {
// SubReq is a wrapper over the Subscriber model.
type SubReq struct {
models.Subscriber
Lists pq.Int64Array `json:"lists"`
Lists pq.Int64Array `json:"lists"`
ListUUIDs pq.StringArray `json:"list_uuids"`
}
type importStatusTpl struct {
@ -562,8 +563,11 @@ func (s *Session) mapCSVHeaders(csvHdrs []string, knownHdrs map[string]bool) map
// ValidateFields validates incoming subscriber field values.
func ValidateFields(s SubReq) error {
if len(s.Email) > 1000 {
return errors.New(`e-mail too long`)
}
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) {
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.
if err := c.Bind(&req); err != nil {
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())
}
// Insert and read ID.
var (
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)
// Insert the subscriber into the DB.
subID, err := insertSubscriber(req, app)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err))
return err
}
// 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.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", req.ID))
c.SetParamValues(fmt.Sprintf("%d", subID))
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)
}
// 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,
// subscriptions, campaign_views, link_clicks (if they're enabled in the config)
// and returns a formatted, indented JSON payload. Either takes a numeric id