Add 'send campaign test' feature
This commit is contained in:
parent
3a1faf0faa
commit
d89b22e757
8 changed files with 276 additions and 116 deletions
100
campaigns.go
100
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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=" " 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 &&
|
||||
|
|
|
@ -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
13
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()
|
||||
|
|
|
@ -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"`
|
||||
|
|
14
queries.sql
14
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;
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue