Add 'send campaign test' feature

This commit is contained in:
Kailash Nadh 2018-10-29 15:20:49 +05:30
parent 3a1faf0faa
commit d89b22e757
8 changed files with 276 additions and 116 deletions

View File

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

View File

@ -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) => {
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) {
@ -209,6 +212,7 @@ class TheFormDef extends React.PureComponent {
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,6 +222,7 @@ 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 })
})
@ -228,11 +233,41 @@ class TheFormDef extends React.PureComponent {
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,6 +279,7 @@ class TheFormDef extends React.PureComponent {
return (
<div>
<Spin spinning={ this.state.loading }>
<Form onSubmit={ this.handleSubmit }>
<Form.Item {...formItemLayout} label="Campaign name">
{getFieldDecorator("name", {
@ -328,17 +364,22 @@ class TheFormDef extends React.PureComponent {
</Button>
</Form.Item>
}
</Form>
{ this.props.isSingle &&
<div>
<hr />
<Form.Item {...formItemLayout} label="Send test e-mails" extra="Hit Enter after typing an e-mail to add multiple emails">
<Select mode="tags" style={{ minWidth: 320 }}></Select>
<div><Button htmlType="submit"><Icon type="mail" /> Send test</Button></div>
<Form.Item {...formItemLayout} label="Send test messages" extra="Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers.">
{getFieldDecorator("subscribers")(
<Select mode="tags" style={{ width: "100%" }}></Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="&nbsp;" colon={ false }>
<Button onClick={ this.handleTestCampaign }><Icon type="mail" /> Send test</Button>
</Form.Item>
</div>
}
</Form>
</Spin>
</div>
)
@ -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 {
<Media { ...{ ...this.props,
insertMedia: this.state.editor ? this.state.editor.insertMedia : null,
onCancel: this.toggleMedia,
onOk: this.toggleMedia
}} />
onOk: this.toggleMedia }} />
</Modal>
{ this.state.previewRecord &&

View File

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

13
main.go
View File

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

View File

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

View File

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

View File

@ -44,9 +44,9 @@ var jsonMap = []byte("{}")
func handleGetSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
out models.Subscribers
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))
}