diff --git a/frontend/src/Forms.js b/frontend/src/Forms.js
new file mode 100644
index 0000000..94354f3
--- /dev/null
+++ b/frontend/src/Forms.js
@@ -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 (
+
+ Forms
+
+
+ {
+ return { label: l.name, value: l.uuid }
+ })}
+ defaultValue={[]}
+ onChange={(sel) => this.handleSelection(sel)}
+ />
+
+
+ Form HTML
+ 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.
+
+
+
+{`
+`}
+
+
+
+
+
+ )
+ }
+}
+
+export default Forms
diff --git a/frontend/src/Layout.js b/frontend/src/Layout.js
index f84f867..6e19cdc 100644
--- a/frontend/src/Layout.js
+++ b/frontend/src/Layout.js
@@ -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 {
Dashboard
-
-
-
- Lists
-
-
+
+
+ Lists
+
+ }
+ >
+
+
+
+ All lists
+
+
+
+
+
+ Forms
+
+
+
)}
/>
+ (
+
+ )}
+ />
listmonk
{" "}
- © 2019 {year !== 2019 ? " - " + year : ""}.
- Version { process.env.REACT_APP_VERSION } —{" "}
+ © 2019 {year !== 2019 ? " - " + year : ""}. Version{" "}
+ {process.env.REACT_APP_VERSION} —{" "}
- );
+ )
}
}
-export default Base;
+export default Base
diff --git a/frontend/src/Lists.js b/frontend/src/Lists.js
index b853e28..b700e61 100644
--- a/frontend/src/Lists.js
+++ b/frontend/src/Lists.js
@@ -93,7 +93,6 @@ class CreateFormDef extends React.PureComponent {
}
modalTitle(formType, record) {
- console.log(formType)
if (formType === cs.FormCreate) {
return "Create a list"
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 4d356b2..ec59ae5 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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 */
diff --git a/handlers.go b/handlers.go
index e462e4b..8f44e53 100644
--- a/handlers.go
+++ b/handlers.go
@@ -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),
diff --git a/public.go b/public.go
index 802f00d..920be8d 100644
--- a/public.go
+++ b/public.go
@@ -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 (
diff --git a/queries.sql b/queries.sql
index 12b71d0..55fd56e 100644
--- a/queries.sql
+++ b/queries.sql
@@ -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;
diff --git a/subimporter/importer.go b/subimporter/importer.go
index 714700b..2aeee21 100644
--- a/subimporter/importer.go
+++ b/subimporter/importer.go
@@ -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 + `"`)
diff --git a/subscribers.go b/subscribers.go
index f2c9fdb..c62daa6 100644
--- a/subscribers.go
+++ b/subscribers.go
@@ -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