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. +

+
+
+{`
+
+

Subscribe

+

+

`} +{(() => { + let out = []; + this.state.selected.forEach(l => { + out.push(` +

+ + +

`); + }); + return out; +})()} +{` +

+
+
+`} + +
+ +
+
+ ) + } +} + +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