浏览代码

Add subscription forms

Kailash Nadh 5 年之前
父节点
当前提交
c08ca14a5b
共有 9 个文件被更改,包括 264 次插入56 次删除
  1. 96 0
      frontend/src/Forms.js
  2. 57 32
      frontend/src/Layout.js
  3. 0 1
      frontend/src/Lists.js
  4. 17 0
      frontend/src/index.css
  5. 1 0
      handlers.go
  6. 48 0
      public.go
  7. 7 2
      queries.sql
  8. 6 2
      subimporter/importer.go
  9. 32 19
      subscribers.go

+ 96 - 0
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 (
+      <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

+ 57 - 32
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.
 // 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 {
 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>
-            <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
             <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 : ""}.
-              Version { process.env.REACT_APP_VERSION } &mdash;{" "}
+              &copy; 2019 {year !== 2019 ? " - " + year : ""}. Version{" "}
+              {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

+ 0 - 1
frontend/src/Lists.js

@@ -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"
     }
     }

+ 17 - 0
frontend/src/index.css

@@ -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 */

+ 1 - 0
handlers.go

@@ -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),

+ 48 - 0
public.go

@@ -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 (

+ 7 - 2
queries.sql

@@ -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;

+ 6 - 2
subimporter/importer.go

@@ -80,7 +80,8 @@ type Status struct {
 // SubReq is a wrapper over the Subscriber model.
 // SubReq is a wrapper over the Subscriber model.
 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 + `"`)

+ 32 - 19
subscribers.go

@@ -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.
-	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 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.
 	// 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