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) => {
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 (
<div>
<Form onSubmit={ this.handleSubmit }>
<Form.Item {...formItemLayout} label="Campaign name">
{getFieldDecorator("name", {
extra: "This is internal and will not be visible to subscribers",
initialValue: record.name,
rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled }autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Subject">
{getFieldDecorator("subject", {
initialValue: record.subject,
rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled } maxLength="500" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="From address">
{getFieldDecorator("from_email", {
initialValue: record.from_email,
rules: [{ required: true }, { validator: this.validateEmail }]
})(<Input disabled={ this.props.formDisabled } placeholder="Company Name <email@company.com>" maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to">
{getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })(
<Select disabled={ this.props.formDisabled } mode="multiple">
{[...this.props.data[cs.ModelLists]].map((v, i) =>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Template" extra="Template">
{getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })(
<Select disabled={ this.props.formDisabled }>
{[...this.props.data[cs.ModelTemplates]].map((v, i) =>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags">
{getFieldDecorator("tags", { initialValue: record.tags })(
<Select disabled={ this.props.formDisabled } mode="tags"></Select>
<Spin spinning={ this.state.loading }>
<Form onSubmit={ this.handleSubmit }>
<Form.Item {...formItemLayout} label="Campaign name">
{getFieldDecorator("name", {
extra: "This is internal and will not be visible to subscribers",
initialValue: record.name,
rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled }autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Subject">
{getFieldDecorator("subject", {
initialValue: record.subject,
rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled } maxLength="500" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="From address">
{getFieldDecorator("from_email", {
initialValue: record.from_email,
rules: [{ required: true }, { validator: this.validateEmail }]
})(<Input disabled={ this.props.formDisabled } placeholder="Company Name <email@company.com>" maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to">
{getFieldDecorator("lists", { initialValue: subLists, rules: [{ required: true }] })(
<Select disabled={ this.props.formDisabled } mode="multiple">
{[...this.props.data[cs.ModelLists]].map((v, i) =>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Template" extra="Template">
{getFieldDecorator("template_id", { initialValue: record.template_id, rules: [{ required: true }] })(
<Select disabled={ this.props.formDisabled }>
{[...this.props.data[cs.ModelTemplates]].map((v, i) =>
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags">
{getFieldDecorator("tags", { initialValue: record.tags })(
<Select disabled={ this.props.formDisabled } mode="tags"></Select>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Messenger">
{getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })(
<Radio.Group className="messengers">
{[...this.props.messengers].map((v, i) =>
<Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio>
)}
</Radio.Group>
)}
</Form.Item>
<Form.Item {...formItemLayout} label="Messenger">
{getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })(
<Radio.Group className="messengers">
{[...this.props.messengers].map((v, i) =>
<Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio>
)}
</Radio.Group>
)}
</Form.Item>
<hr />
<Form.Item {...formItemLayout} label="Send later?">
<Row>
<Col span={ 2 }>
{getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })(
<Switch disabled={ this.props.formDisabled }
checked={ this.state.sendLater }
onChange={ this.handleSendLater } />
)}
</Col>
<Col span={ 12 }>
{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") })(
<DatePicker
disabled={ this.props.formDisabled }
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time"
/>
)}
</Col>
</Row>
</Form.Item>
{ !this.props.formDisabled &&
<Form.Item {...formItemTailLayout}>
<Button htmlType="submit" type="primary">
<Icon type="save" /> { !this.props.isSingle ? "Continue" : "Save changes" }
</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 later?">
<Row>
<Col span={ 2 }>
{getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })(
<Switch disabled={ this.props.formDisabled }
checked={ this.state.sendLater }
onChange={ this.handleSendLater } />
)}
</Col>
<Col span={ 12 }>
{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") })(
<DatePicker
disabled={ this.props.formDisabled }
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time"
/>
)}
</Col>
</Row>
</Form.Item>
</div>
}
{ !this.props.formDisabled &&
<Form.Item {...formItemTailLayout}>
<Button htmlType="submit" type="primary">
<Icon type="save" /> { !this.props.isSingle ? "Continue" : "Save changes" }
</Button>
</Form.Item>
}
{ this.props.isSingle &&
<div>
<hr />
<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

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

View File

@ -20,11 +20,11 @@ const (
tplTag = `{{ template "content" . }}`
dummyTpl = `
<p>Hi there</p>
<p>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.</p>
<p>Hi there</p>
<p>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.</p>
<h3>Sub heading</h3>
<p>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.</p>
<h3>Sub heading</h3>
<p>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.</p>
<p>Here is a link to <a href="https://listmonk.app" target="_blank">listmonk</a>.</p>`
)