diff --git a/campaigns.go b/campaigns.go index cd17ea7..f859c20 100644 --- a/campaigns.go +++ b/campaigns.go @@ -8,6 +8,7 @@ import ( "net/http" "regexp" "strconv" + "strings" "time" "github.com/asaskevich/govalidator" @@ -24,6 +25,9 @@ type campaignReq struct { models.Campaign MessengerID string `json:"messenger"` Lists pq.Int64Array `json:"lists"` + + // This is only relevant to campaign test requests. + SubscriberEmails pq.StringArray `json:"subscribers"` } type campaignStats struct { @@ -131,7 +135,8 @@ func handlePreviewCampaign(c echo.Context) error { } tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, body) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err)) + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Error compiling template: %v", err)) } // Render the message body. @@ -139,7 +144,8 @@ func handlePreviewCampaign(c echo.Context) error { if err := tpl.ExecuteTemplate(&out, runner.BaseTPL, runner.Message{Campaign: &camp, Subscriber: &sub, UnsubscribeURL: "#dummy"}); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error executing template: %v", err)) + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Error executing template: %v", err)) } return c.HTML(http.StatusOK, out.String()) @@ -408,6 +414,91 @@ func handleGetCampaignMessengers(c echo.Context) error { return c.JSON(http.StatusOK, okResp{app.Runner.GetMessengerNames()}) } +// handleTestCampaign handles the sending of a campaign message to +// arbitrary subscribers for testing. +func handleTestCampaign(c echo.Context) error { + var ( + app = c.Get("app").(*App) + campID, _ = strconv.Atoi(c.Param("id")) + req campaignReq + ) + + if campID < 1 { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid campaign ID.") + } + + // Get and validate fields. + if err := c.Bind(&req); err != nil { + return err + } + // Validate. + if err := validateCampaignFields(req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if len(req.SubscriberEmails) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.") + } + + // Get the subscribers. + for i := 0; i < len(req.SubscriberEmails); i++ { + req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i])) + } + var subs models.Subscribers + if err := app.Queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + fmt.Sprintf("Error fetching subscribers: %s", pqErrMsg(err))) + } else if len(subs) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "No known subscribers given.") + } + + // The campaign. + var camp models.Campaign + if err := app.Queries.GetCampaignForPreview.Get(&camp, campID); err != nil { + if err == sql.ErrNoRows { + return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") + } + return echo.NewHTTPError(http.StatusInternalServerError, + fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) + } + + // Override certain values in the DB with incoming values. + camp.Name = req.Name + camp.Subject = req.Subject + camp.FromEmail = req.FromEmail + camp.Body = req.Body + + // Send the test messages. + for _, s := range subs { + if err := sendTestMessage(&s, &camp, app); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error sending test: %v", err)) + } + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +// sendTestMessage takes a campaign and a subsriber and sends out a sample campain message. +func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) error { + tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, camp.Body) + if err != nil { + return fmt.Errorf("Error compiling template: %v", err) + } + + // Render the message body. + var out = bytes.Buffer{} + if err := tpl.ExecuteTemplate(&out, + runner.BaseTPL, + runner.Message{Campaign: camp, Subscriber: sub, UnsubscribeURL: "#dummy"}); err != nil { + return fmt.Errorf("Error executing template: %v", err) + } + + if err := app.Messenger.Push(camp.FromEmail, sub.Email, camp.Subject, []byte(out.Bytes())); err != nil { + return err + } + + return nil +} + // validateCampaignFields validates incoming campaign field values. func validateCampaignFields(c campaignReq) error { if !regexFromAddress.Match([]byte(c.FromEmail)) { @@ -434,6 +525,11 @@ func validateCampaignFields(c campaignReq) error { } } + _, err := runner.CompileMessageTemplate(tplTag, c.Body) + if err != nil { + return fmt.Errorf("Error compiling campaign body: %v", err) + } + return nil } diff --git a/frontend/my/src/Campaign.js b/frontend/my/src/Campaign.js index e43ef9c..1d8c005 100644 --- a/frontend/my/src/Campaign.js +++ b/frontend/my/src/Campaign.js @@ -25,7 +25,7 @@ class Editor extends React.PureComponent { rawInput: null, selContentType: "richtext", contentType: "richtext", - body: "", + body: "" } quillModules = { @@ -129,7 +129,9 @@ class Editor extends React.PureComponent { modules={ this.quillModules } defaultValue={ this.props.record.body } ref={ (o) => { - this.setState({ quill: o }) + if(o) { + this.setState({ quill: o }) + } }} onChange={ () => { if(!this.state.quill) { @@ -167,7 +169,8 @@ class Editor extends React.PureComponent { class TheFormDef extends React.PureComponent { state = { editorVisible: false, - sendLater: false + sendLater: false, + loading: false } componentWillReceiveProps(nextProps) { @@ -200,15 +203,16 @@ class TheFormDef extends React.PureComponent { if (err) { return } - + if(!values.tags) { values.tags = [] } - + values.body = this.props.body values.content_type = this.props.contentType - + // Create a new campaign. + this.setState({ loading: true }) if(!this.props.isSingle) { this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, values).then((resp) => { notification["success"]({ placement: "topRight", @@ -218,21 +222,52 @@ class TheFormDef extends React.PureComponent { this.props.route.history.push(cs.Routes.ViewCampaign.replace(":id", resp.data.data.id)) this.props.fetchRecord(resp.data.data.id) this.props.setCurrentTab("content") + this.setState({ loading: false }) }).catch(e => { notification["error"]({ message: "Error", description: e.message }) }) } else { this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaign, cs.MethodPut, { ...values, id: this.props.record.id }).then((resp) => { notification["success"]({ placement: "topRight", - message: "Campaign updated", - description: `"${values["name"]}" updated` }) + message: "Campaign updated", + description: `"${values["name"]}" updated` }) }).catch(e => { notification["error"]({ message: "Error", description: e.message }) + this.setState({ loading: false }) }) } }) } + + handleTestCampaign = (e) => { + e.preventDefault() + this.props.form.validateFields((err, values) => { + if (err) { + return + } + + if(!values.tags) { + values.tags = [] + } + + values.id = this.props.record.id + values.body = this.props.body + values.content_type = this.props.contentType + + this.setState({ loading: true }) + this.props.request(cs.Routes.TestCampaign, cs.MethodPost, values).then((resp) => { + this.setState({ loading: false }) + notification["success"]({ placement: "topRight", + message: "Test sent", + description: `Test messages sent` }) + }).catch(e => { + this.setState({ loading: false }) + notification["error"]({ message: "Error", description: e.message }) + }) + }) + } + render() { const { record } = this.props; const { getFieldDecorator } = this.props.form @@ -244,101 +279,107 @@ class TheFormDef extends React.PureComponent { return (
-
- - {getFieldDecorator("name", { - extra: "This is internal and will not be visible to subscribers", - initialValue: record.name, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("subject", { - initialValue: record.subject, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("from_email", { - initialValue: record.from_email, - rules: [{ required: true }, { validator: this.validateEmail }] - })()} - - - {getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })( - - )} - - - {getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })( - - )} - - - {getFieldDecorator("tags", { initialValue: record.tags })( - + + + + {getFieldDecorator("name", { + extra: "This is internal and will not be visible to subscribers", + initialValue: record.name, + rules: [{ required: true }] + })()} + + + {getFieldDecorator("subject", { + initialValue: record.subject, + rules: [{ required: true }] + })()} + + + {getFieldDecorator("from_email", { + initialValue: record.from_email, + rules: [{ required: true }, { validator: this.validateEmail }] + })()} + + + {getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })( + + )} + + + {getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })( + + )} + + + {getFieldDecorator("tags", { initialValue: record.tags })( + + )} + + + {getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })( + + {[...this.props.messengers].map((v, i) => + { v } + )} + )} - - - {getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })( - - {[...this.props.messengers].map((v, i) => - { v } - )} - - )} - - -
- - - - {getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })( - - )} - - - {this.state.sendLater && getFieldDecorator("send_at", - { initialValue: (record && typeof(record.send_at) === "string") ? moment(record.send_at) : moment(new Date()).add(1, "days").startOf("day") })( - - )} - - - - - { !this.props.formDisabled && - - - } - - { this.props.isSingle && -

- - -
+ + + + {getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })( + + )} + + + {this.state.sendLater && getFieldDecorator("send_at", + { initialValue: (record && typeof(record.send_at) === "string") ? moment(record.send_at) : moment(new Date()).add(1, "days").startOf("day") })( + + )} + + -
- } + + { !this.props.formDisabled && + + + + } + + { this.props.isSingle && +
+
+ + {getFieldDecorator("subscribers")( + + )} + + + + +
+ } + +
) @@ -381,7 +422,7 @@ class Campaign extends React.PureComponent { } fetchRecord = (id) => { - this.props.request(cs.Routes.GetCampaigns, cs.MethodGet, { id: id }).then((r) => { + this.props.request(cs.Routes.GetCampaign, cs.MethodGet, { id: id }).then((r) => { const record = r.data.data this.setState({ record: record, loading: false }) @@ -440,7 +481,7 @@ class Campaign extends React.PureComponent { record={ this.state.record } isSingle={ this.state.record.id ? true : false } messengers={ this.state.messengers } - body={ this.state.body } + body={ this.state.body ? this.state.body : this.state.record.body } contentType={ this.state.contentType } formDisabled={ this.state.formDisabled } fetchRecord={ this.fetchRecord } @@ -476,8 +517,7 @@ class Campaign extends React.PureComponent { + onOk: this.toggleMedia }} /> { this.state.previewRecord && diff --git a/frontend/my/src/constants.js b/frontend/my/src/constants.js index 272aa2a..c5545f5 100644 --- a/frontend/my/src/constants.js +++ b/frontend/my/src/constants.js @@ -70,8 +70,10 @@ export const Routes = { ViewCampaign: "/campaigns/:id", GetCampaignMessengers: "/api/campaigns/messengers", GetCampaigns: "/api/campaigns", + GetCampaign: "/api/campaigns/:id", GetRunningCampaignStats: "/api/campaigns/running/stats", CreateCampaign: "/api/campaigns", + TestCampaign: "/api/campaigns/:id/test", UpdateCampaign: "/api/campaigns/:id", UpdateCampaignStatus: "/api/campaigns/:id/status", DeleteCampaign: "/api/campaigns/:id", diff --git a/main.go b/main.go index d720fe6..5e90de2 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,8 @@ type App struct { Importer *subimporter.Importer Runner *runner.Runner Logger *log.Logger + + Messenger messenger.Messenger } func init() { @@ -104,6 +106,7 @@ func registerHandlers(e *echo.Echo) { e.GET("/api/campaigns/:id", handleGetCampaigns) e.GET("/api/campaigns/:id/preview", handlePreviewCampaign) e.POST("/api/campaigns/:id/preview", handlePreviewCampaign) + e.POST("/api/campaigns/:id/test", handleTestCampaign) e.POST("/api/campaigns", handleCreateCampaign) e.PUT("/api/campaigns/:id", handleUpdateCampaign) e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus) @@ -139,7 +142,7 @@ func registerHandlers(e *echo.Echo) { } // initMessengers initializes various messaging backends. -func initMessengers(r *runner.Runner) { +func initMessengers(r *runner.Runner) messenger.Messenger { // Load SMTP configurations for the default e-mail Messenger. var srv []messenger.Server for name := range viper.GetStringMapString("smtp") { @@ -158,14 +161,16 @@ func initMessengers(r *runner.Runner) { logger.Printf("loaded SMTP config %s (%s@%s)", s.Name, s.Username, s.Host) } - e, err := messenger.NewEmailer(srv...) + msgr, err := messenger.NewEmailer(srv...) if err != nil { logger.Fatalf("error loading e-mail messenger: %v", err) } - if err := r.AddMessenger(e); err != nil { + if err := r.AddMessenger(msgr); err != nil { logger.Printf("error registering messenger %s", err) } + + return msgr } func main() { @@ -222,7 +227,7 @@ func main() { app.Runner = r // Add messengers. - initMessengers(app.Runner) + app.Messenger = initMessengers(app.Runner) go r.Run(time.Duration(time.Second * 2)) r.SpawnWorkers() diff --git a/queries.go b/queries.go index c10481b..8be4ccc 100644 --- a/queries.go +++ b/queries.go @@ -10,6 +10,7 @@ import ( type Queries struct { UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"` GetSubscriber *sqlx.Stmt `query:"get-subscriber"` + GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` QuerySubscribers string `query:"query-subscribers"` QuerySubscribersCount string `query:"query-subscribers-count"` diff --git a/queries.sql b/queries.sql index 0c698d2..2425800 100644 --- a/queries.sql +++ b/queries.sql @@ -3,6 +3,11 @@ -- Get a single subscriber by id or UUID. SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; +-- subscribers +-- name: get-subscribers-by-emails +-- Get subscribers by emails. +SELECT * FROM subscribers WHERE email=ANY($1); + -- name: get-subscriber-lists -- Get lists belonging to subscribers. SELECT lists.*, subscriber_lists.subscriber_id, subscriber_lists.status AS subscription_status FROM lists @@ -158,7 +163,14 @@ WHERE ($1 = 0 OR id = $1) AND status=(CASE WHEN $2 != '' THEN $2::campaign_statu ORDER BY created_at DESC OFFSET $3 LIMIT $4; -- name: get-campaign-for-preview -SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body +SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body, +( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(l)), '[]') FROM ( + SELECT COALESCE(campaign_lists.list_id, 0) AS id, + campaign_lists.list_name AS name + FROM campaign_lists WHERE campaign_lists.campaign_id = campaigns.id + ) l +) AS lists FROM campaigns LEFT JOIN templates ON (templates.id = campaigns.template_id) WHERE campaigns.id = $1; diff --git a/subscribers.go b/subscribers.go index 4e49846..acda01c 100644 --- a/subscribers.go +++ b/subscribers.go @@ -43,10 +43,10 @@ var jsonMap = []byte("{}") // handleGetSubscriber handles the retrieval of a single subscriber by ID. func handleGetSubscriber(c echo.Context) error { var ( - app = c.Get("app").(*App) - out models.Subscribers - + app = c.Get("app").(*App) id, _ = strconv.Atoi(c.Param("id")) + + out models.Subscribers ) if id < 1 { @@ -183,6 +183,10 @@ func handleCreateSubscriber(c echo.Context) error { true, req.Lists) if err != nil { + if err == sql.ErrNoRows { + return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.") + } + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error creating subscriber: %v", err)) } diff --git a/templates.go b/templates.go index 15c4b61..364fc4b 100644 --- a/templates.go +++ b/templates.go @@ -20,11 +20,11 @@ const ( tplTag = `{{ template "content" . }}` dummyTpl = ` -

Hi there

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.

+

Hi there

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.

-

Sub heading

-

Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.

+

Sub heading

+

Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.

Here is a link to listmonk.

` )