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))
|
||||
}
|
||||
|
|
|
@ -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>`
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue