Fix merge conflict

This commit is contained in:
Kailash Nadh 2018-10-31 18:26:51 +05:30
commit 39ba75b420
15 changed files with 272 additions and 116 deletions

View File

@ -95,14 +95,14 @@ func handlePreviewCampaign(c echo.Context) error {
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
body = c.FormValue("body") body = c.FormValue("body")
camp models.Campaign camp = &models.Campaign{}
) )
if id < 1 { if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
} }
err := app.Queries.GetCampaignForPreview.Get(&camp, id) err := app.Queries.GetCampaignForPreview.Get(camp, id)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.") return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
@ -130,25 +130,23 @@ func handlePreviewCampaign(c echo.Context) error {
} }
// Compile the template. // Compile the template.
if body == "" { if body != "" {
body = camp.Body camp.Body = body
} }
tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, body)
if err != nil { if err := camp.CompileTemplate(app.Runner.TemplateFuncs(camp)); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error compiling template: %v", err)) fmt.Sprintf("Error compiling template: %v", err))
} }
// Render the message body. // Render the message body.
var out = bytes.Buffer{} m := app.Runner.NewMessage(camp, &sub)
if err := tpl.ExecuteTemplate(&out, if err := m.Render(); err != nil {
runner.BaseTPL,
runner.Message{Campaign: &camp, Subscriber: &sub, UnsubscribeURL: "#dummy"}); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error executing template: %v", err)) fmt.Sprintf("Error rendering message: %v", err))
} }
return c.HTML(http.StatusOK, out.String()) return c.HTML(http.StatusOK, string(m.Body))
} }
// handleCreateCampaign handles campaign creation. // handleCreateCampaign handles campaign creation.
@ -479,14 +477,13 @@ func handleTestCampaign(c echo.Context) error {
// sendTestMessage takes a campaign and a subsriber and sends out a sample campain message. // 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 { func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) error {
tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, camp.Body) if err := camp.CompileTemplate(app.Runner.TemplateFuncs(camp)); err != nil {
if err != nil {
return fmt.Errorf("Error compiling template: %v", err) return fmt.Errorf("Error compiling template: %v", err)
} }
// Render the message body. // Render the message body.
var out = bytes.Buffer{} var out = bytes.Buffer{}
if err := tpl.ExecuteTemplate(&out, if err := camp.Tpl.ExecuteTemplate(&out,
runner.BaseTPL, runner.BaseTPL,
runner.Message{Campaign: camp, Subscriber: sub, UnsubscribeURL: "#dummy"}); err != nil { runner.Message{Campaign: camp, Subscriber: sub, UnsubscribeURL: "#dummy"}); err != nil {
return fmt.Errorf("Error executing template: %v", err) return fmt.Errorf("Error executing template: %v", err)

View File

@ -119,6 +119,7 @@ class TheFormDef extends React.PureComponent {
</div> </div>
</Form.Item> </Form.Item>
<Form.Item {...formItemTailLayout}> <Form.Item {...formItemTailLayout}>
<p className="text-grey">For existing subscribers, the names and attributes will be overwritten with the values in the CSV.</p>
<Button type="primary" htmlType="submit"><Icon type="upload" /> Upload &amp; import</Button> <Button type="primary" htmlType="submit"><Icon type="upload" /> Upload &amp; import</Button>
</Form.Item> </Form.Item>
</Form> </Form>
@ -302,7 +303,7 @@ class Import extends React.PureComponent {
(<code>status</code> and <code>attributes</code> are optional). (<code>status</code> and <code>attributes</code> are optional).
{" "} {" "}
<code>attributes</code> should be a valid JSON string with double escaped quotes. <code>attributes</code> should be a valid JSON string with double escaped quotes.
Spreadsheet programs should automatically take care of this without having to manually Spreadsheet programs should automatically take care of this without having you manually
escape quotes. escape quotes.
</p> </p>

View File

@ -128,6 +128,7 @@ func registerHandlers(e *echo.Echo) {
// Subscriber facing views. // Subscriber facing views.
e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage) e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage) e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect)
// Static views. // Static views.
e.GET("/lists", handleIndexPage) e.GET("/lists", handleIndexPage)
@ -223,6 +224,8 @@ func main() {
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid} // url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL), UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
// url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid}
LinkTrackURL: fmt.Sprintf("%s/link/%%s/%%s/%%s", app.Constants.RootURL),
}, newRunnerDB(q), logger) }, newRunnerDB(q), logger)
app.Runner = r app.Runner = r

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"regexp"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types" "github.com/jmoiron/sqlx/types"
@ -37,6 +38,20 @@ const (
UserTypeUser = "user" UserTypeUser = "user"
UserStatusEnabled = "enabled" UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled" UserStatusDisabled = "disabled"
// BaseTpl is the name of the base template.
BaseTpl = "base"
// ContentTpl is the name of the compiled message.
ContentTpl = "content"
)
// Regular expression for matching {{ Track "http://link.com" }} in the template
// and substituting it with {{ Track "http://link.com" .Campaign.UUID .Subscriber.UUID }}
// before compilation. This string gimmick is to make linking easier for users.
var (
regexpLinkTag = regexp.MustCompile(`{{(\s+)?Track\s+?"(.+?)"(\s+)?}}`)
regexpLinkTagReplace = `{{ Track "$2" .Campaign.UUID .Subscriber.UUID }}`
) )
// Base holds common fields shared across models. // Base holds common fields shared across models.
@ -187,3 +202,29 @@ func (s SubscriberAttribs) Scan(src interface{}) error {
} }
return fmt.Errorf("Could not not decode type %T -> %T", src, s) return fmt.Errorf("Could not not decode type %T -> %T", src, s)
} }
// CompileTemplate compiles a campaign body template into its base
// template and sets the resultant template to Campaign.Tpl
func (c *Campaign) CompileTemplate(f template.FuncMap) error {
// Compile the base template.
t := regexpLinkTag.ReplaceAllString(c.TemplateBody, regexpLinkTagReplace)
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(t)
if err != nil {
return fmt.Errorf("error compiling base template: %v", err)
}
// Compile the campaign message.
t = regexpLinkTag.ReplaceAllString(c.Body, regexpLinkTagReplace)
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(t)
if err != nil {
return fmt.Errorf("error compiling message: %v", err)
}
out, err := baseTPL.AddParseTree(ContentTpl, msgTpl.Tree)
if err != nil {
return fmt.Errorf("error inserting child template: %v", err)
}
c.Tpl = out
return nil
}

View File

@ -10,6 +10,7 @@ import (
"github.com/labstack/echo" "github.com/labstack/echo"
) )
// Template wraps a template.Template for echo.
type Template struct { type Template struct {
templates *template.Template templates *template.Template
} }
@ -21,7 +22,8 @@ type publicTpl struct {
type unsubTpl struct { type unsubTpl struct {
publicTpl publicTpl
Blacklisted bool Unsubscribe bool
Blacklist bool
} }
type errorTpl struct { type errorTpl struct {
@ -33,8 +35,8 @@ type errorTpl struct {
var regexValidUUID = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") var regexValidUUID = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
// Render executes and renders a template for echo.
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
// fmt.Println(t.templates.ExecuteTemplate(os.Stdout, name, nil))
return t.templates.ExecuteTemplate(w, name, data) return t.templates.ExecuteTemplate(w, name, data)
} }
@ -44,37 +46,66 @@ func handleUnsubscribePage(c echo.Context) error {
app = c.Get("app").(*App) app = c.Get("app").(*App)
campUUID = c.Param("campUUID") campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID") subUUID = c.Param("subUUID")
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist")) blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
out = unsubTpl{} out = unsubTpl{}
) )
out.Blacklisted = blacklist out.Unsubscribe = unsub
out.Blacklist = blacklist
out.Title = "Unsubscribe from mailing list" out.Title = "Unsubscribe from mailing list"
if !regexValidUUID.MatchString(campUUID) || if !regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) { !regexValidUUID.MatchString(subUUID) {
err := errorTpl{} return c.Render(http.StatusBadRequest, "error",
err.Title = "Invalid request" makeErrorTpl("Invalid request", "",
err.ErrorTitle = err.Title `The unsubscription request contains invalid IDs.
err.ErrorMessage = "The unsubscription request contains invalid IDs. Please make sure to follow the correct link." Please click on the correct link.`))
return c.Render(http.StatusBadRequest, "error", err)
} }
// Unsubscribe. // Unsubscribe.
res, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist) if unsub {
if err != nil { res, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist)
app.Logger.Printf("Error unsubscribing : %v", err) if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Subscription doesn't exist") app.Logger.Printf("Error unsubscribing : %v", err)
} return echo.NewHTTPError(http.StatusBadRequest, "There was an internal error while unsubscribing you.")
}
num, err := res.RowsAffected() if !blacklist {
if num == 0 { num, _ := res.RowsAffected()
err := errorTpl{} if num == 0 {
err.Title = "Invalid subscription" return c.Render(http.StatusBadRequest, "error",
err.ErrorTitle = err.Title makeErrorTpl("Already unsubscribed", "",
err.ErrorMessage = "Looks like you are not subscribed to this mailing list." `Looks like you are not subscribed to this mailing list.
return c.Render(http.StatusBadRequest, "error", err) You may have already unsubscribed.`))
}
}
} }
return c.Render(http.StatusOK, "unsubscribe", out) return c.Render(http.StatusOK, "unsubscribe", out)
} }
// handleLinkRedirect handles link UUID to real link redirection.
func handleLinkRedirect(c echo.Context) error {
var (
app = c.Get("app").(*App)
linkUUID = c.Param("linkUUID")
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
)
if !regexValidUUID.MatchString(linkUUID) ||
!regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) {
return c.Render(http.StatusBadRequest, "error",
makeErrorTpl("Invalid link", "", "The link you clicked is invalid."))
}
var url string
if err := app.Queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
app.Logger.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, "error",
makeErrorTpl("Error opening link", "", "There was an error opening the link. Please try later."))
}
return c.Redirect(http.StatusTemporaryRedirect, url)
}

View File

@ -18,18 +18,25 @@ h1, h2, h3, h4 {
} }
.button { .button {
border: 0; background: #7f2aff;
background: transparent;
padding: 10px 30px; padding: 10px 30px;
border-radius: 3px; border-radius: 3px;
border: 1px solid #ddd; border: 0;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
color: #111; color: #ffff;
display: inline-block; display: inline-block;
} }
.button:hover { .button:hover {
background: #f3f3f3; background: #333;
}
.button .button-outline {
background: transparent;
border: 1px solid #ddd;
color: #444'
}
.button .button-outline:hover {
color: #111;
} }
.wrap { .wrap {

View File

@ -27,20 +27,6 @@
Powered by <a target="_blank" href="https://listmonk.app">listmonk</a> Powered by <a target="_blank" href="https://listmonk.app">listmonk</a>
</footer> </footer>
</div> </div>
<script>
function unsubAll(e) {
if(!confirm("Are you sure?")) {
e.preventDefault();
return false;
}
}
(function() {
document.querySelector("#btn-unsuball").onclick = unsubAll
})();
</script>
</body> </body>
</html> </html>
{{ end }} {{ end }}

View File

@ -1,21 +1,34 @@
{{ define "unsubscribe" }} {{ define "unsubscribe" }}
{{ template "header" .}} {{ template "header" .}}
<h2>You have been unsubscribed</h2> {{ if not .Unsubscribe }}
{{ if not .Blacklisted }} <h2>Unsubscribe</h2>
<div class="unsub-all"> <p>Do you wish to unsubscribe from this mailing list?</p>
<p> <form method="post">
Unsubscribe from all future communications? <div>
</p> <input type="hidden" name="unsubscribe" value="true" />
<form method="post"> <button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
<div> </div>
<input type="hidden" name="blacklist" value="true" /> </form>
<button type="submit" class="button" id="btn-unsuball">Unsubscribe all</button> {{ else }}
<h2>You have been unsubscribed</h2>
{{ if not .Blacklist }}
<div class="unsub-all">
<p>
Unsubscribe from all future communications?
</p>
<form method="post">
<div>
<input type="hidden" name="unsubscribe" value="true" />
<input type="hidden" name="blacklist" value="true" />
<button type="submit" class="button button-inline" id="btn-unsuball">Unsubscribe all</button>
</div>
</form>
</div> </div>
</form> {{ else }}
</div> <p>You've been unsubscribed from all future communications.</p>
{{ else }} {{ end }}
<p>You've been unsubscribed from all future communications.</p>
{{ end }} {{ end }}
{{ template "footer" .}} {{ template "footer" .}}

View File

@ -53,6 +53,9 @@ type Queries struct {
SetDefaultTemplate *sqlx.Stmt `query:"set-default-template"` SetDefaultTemplate *sqlx.Stmt `query:"set-default-template"`
DeleteTemplate *sqlx.Stmt `query:"delete-template"` DeleteTemplate *sqlx.Stmt `query:"delete-template"`
CreateLink *sqlx.Stmt `query:"create-link"`
RegisterLinkClick *sqlx.Stmt `query:"register-link-click"`
// GetStats *sqlx.Stmt `query:"get-stats"` // GetStats *sqlx.Stmt `query:"get-stats"`
} }

View File

@ -44,8 +44,8 @@ SELECT COUNT(subscribers.id) as num FROM subscribers INNER JOIN subscriber_lists
-- value is overwritten with the incoming value. This is used for insertions and bulk imports. -- value is overwritten with the incoming value. This is used for insertions and bulk imports.
WITH s AS ( WITH s AS (
INSERT INTO subscribers (uuid, email, name, status, attribs) INSERT INTO subscribers (uuid, email, name, status, attribs)
VALUES($1, $2, $3, (CASE WHEN $4 != '' THEN $4::subscriber_status ELSE 'enabled' END), $5) ON CONFLICT (email) DO UPDATE VALUES($1, $2, $3, $4, $5) ON CONFLICT (email) DO UPDATE
SET name=$3, status=(CASE WHEN $6 IS TRUE THEN $4::subscriber_status ELSE subscribers.status END), SET name=$3, status=(CASE WHEN $6 = true THEN $4 ELSE subscribers.status END),
attribs=$5, updated_at=NOW() attribs=$5, updated_at=NOW()
RETURNING id RETURNING id
) INSERT INTO subscriber_lists (subscriber_id, list_id) ) INSERT INTO subscriber_lists (subscriber_id, list_id)
@ -90,7 +90,7 @@ sub AS (
WHERE uuid = $2 RETURNING id WHERE uuid = $2 RETURNING id
) )
UPDATE subscriber_lists SET status = 'unsubscribed' WHERE UPDATE subscriber_lists SET status = 'unsubscribed' WHERE
subscriber_id = (SELECT id FROM sub) AND subscriber_id = (SELECT id FROM sub) AND status != 'unsubscribed' AND
-- If $3 is false, unsubscribe from the campaign's lists, otherwise all lists. -- If $3 is false, unsubscribe from the campaign's lists, otherwise all lists.
CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END; CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END;
@ -356,9 +356,9 @@ INSERT INTO links (uuid, url) VALUES($1, $2) ON CONFLICT (url) DO UPDATE SET url
-- name: register-link-click -- name: register-link-click
WITH link AS ( WITH link AS (
SELECT url, links.id AS link_id, campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM links SELECT url, links.id AS link_id, campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM links
LEFT JOIN campaigns ON (campaigns.uuid = $1) LEFT JOIN campaigns ON (campaigns.uuid = $2)
LEFT JOIN subscribers ON (subscribers.uuid = $2) LEFT JOIN subscribers ON (subscribers.uuid = $3)
WHERE links.uuid = $3 WHERE links.uuid = $1
) )
INSERT INTO link_clicks (campaign_id, subscriber_id, link_id) INSERT INTO link_clicks (campaign_id, subscriber_id, link_id)
VALUES((SELECT campaign_id FROM link), (SELECT subscriber_id FROM link), (SELECT link_id FROM link)) VALUES((SELECT campaign_id FROM link), (SELECT subscriber_id FROM link), (SELECT link_id FROM link))

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"sync"
"time" "time"
"github.com/knadh/listmonk/messenger" "github.com/knadh/listmonk/messenger"
@ -30,6 +31,7 @@ type DataSource interface {
PauseCampaign(campID int) error PauseCampaign(campID int) error
CancelCampaign(campID int) error CancelCampaign(campID int) error
FinishCampaign(campID int) error FinishCampaign(campID int) error
CreateLink(url string) (string, error)
} }
// Runner handles the scheduling, processing, and queuing of campaigns // Runner handles the scheduling, processing, and queuing of campaigns
@ -43,7 +45,13 @@ type Runner struct {
// Campaigns that are currently running. // Campaigns that are currently running.
camps map[int]*models.Campaign camps map[int]*models.Campaign
msgQueue chan Message // Links generated using Track() are cached here so as to not query
// the database for the link UUID for every message sent. This has to
// be locked as it may be used externally when previewing campaigns.
links map[string]string
linksMutex sync.RWMutex
msgQueue chan *Message
subFetchQueue chan *models.Campaign subFetchQueue chan *models.Campaign
} }
@ -52,14 +60,14 @@ type Message struct {
Campaign *models.Campaign Campaign *models.Campaign
Subscriber *models.Subscriber Subscriber *models.Subscriber
UnsubscribeURL string UnsubscribeURL string
Body []byte
body []byte to string
to string
} }
// Config has parameters for configuring the runner. // Config has parameters for configuring the runner.
type Config struct { type Config struct {
Concurrency int Concurrency int
LinkTrackURL string
UnsubscribeURL string UnsubscribeURL string
} }
@ -70,14 +78,26 @@ func New(cfg Config, src DataSource, l *log.Logger) *Runner {
messengers: make(map[string]messenger.Messenger), messengers: make(map[string]messenger.Messenger),
src: src, src: src,
camps: make(map[int]*models.Campaign, 0), camps: make(map[int]*models.Campaign, 0),
links: make(map[string]string, 0),
logger: l, logger: l,
subFetchQueue: make(chan *models.Campaign, 100), subFetchQueue: make(chan *models.Campaign, 100),
msgQueue: make(chan Message, cfg.Concurrency), msgQueue: make(chan *Message, cfg.Concurrency),
} }
return &r return &r
} }
// NewMessage creates and returns a Message that is made available
// to message templates while they're compiled.
func (r *Runner) NewMessage(c *models.Campaign, s *models.Subscriber) *Message {
return &Message{
to: s.Email,
Campaign: c,
Subscriber: s,
UnsubscribeURL: fmt.Sprintf(r.cfg.UnsubscribeURL, c.UUID, s.UUID),
}
}
// AddMessenger adds a Messenger messaging backend to the runner process. // AddMessenger adds a Messenger messaging backend to the runner process.
func (r *Runner) AddMessenger(msg messenger.Messenger) error { func (r *Runner) AddMessenger(msg messenger.Messenger) error {
id := msg.Name() id := msg.Name()
@ -160,7 +180,7 @@ func (r *Runner) Run(tick time.Duration) {
// SpawnWorkers spawns workers goroutines that push out messages. // SpawnWorkers spawns workers goroutines that push out messages.
func (r *Runner) SpawnWorkers() { func (r *Runner) SpawnWorkers() {
for i := 0; i < r.cfg.Concurrency; i++ { for i := 0; i < r.cfg.Concurrency; i++ {
go func(ch chan Message) { go func(ch chan *Message) {
for { for {
select { select {
case m := <-ch: case m := <-ch:
@ -168,21 +188,25 @@ func (r *Runner) SpawnWorkers() {
m.Campaign.FromEmail, m.Campaign.FromEmail,
m.Subscriber.Email, m.Subscriber.Email,
m.Campaign.Subject, m.Campaign.Subject,
m.body) m.Body)
} }
} }
}(r.msgQueue) }(r.msgQueue)
} }
} }
// TemplateFuncs returns the template functions to be applied into
// compiled campaign templates.
func (r *Runner) TemplateFuncs(c *models.Campaign) template.FuncMap {
return template.FuncMap{
"Track": func(url, campUUID, subUUID string) string {
return r.trackLink(url, campUUID, subUUID)
},
}
}
// addCampaign adds a campaign to the process queue. // addCampaign adds a campaign to the process queue.
func (r *Runner) addCampaign(c *models.Campaign) error { func (r *Runner) addCampaign(c *models.Campaign) error {
var tplErr error
c.Tpl, tplErr = CompileMessageTemplate(c.TemplateBody, c.Body)
if tplErr != nil {
return tplErr
}
// Validate messenger. // Validate messenger.
if _, ok := r.messengers[c.MessengerID]; !ok { if _, ok := r.messengers[c.MessengerID]; !ok {
@ -190,9 +214,13 @@ func (r *Runner) addCampaign(c *models.Campaign) error {
return fmt.Errorf("unknown messenger %s on campaign %s", c.MessengerID, c.Name) return fmt.Errorf("unknown messenger %s on campaign %s", c.MessengerID, c.Name)
} }
// Load the template.
if err := c.CompileTemplate(r.TemplateFuncs(c)); err != nil {
return err
}
// Add the campaign to the active map. // Add the campaign to the active map.
r.camps[c.ID] = c r.camps[c.ID] = c
return nil return nil
} }
@ -225,17 +253,14 @@ func (r *Runner) nextSubscribers(c *models.Campaign, batchSize int) (bool, error
// Push messages. // Push messages.
for _, s := range subs { for _, s := range subs {
to, body, err := r.makeMessage(c, s) m := r.NewMessage(c, s)
if err != nil { if err := m.Render(); err != nil {
r.logger.Printf("error preparing message (%s) (%s): %v", c.Name, s.Email, err) r.logger.Printf("error rendering message (%s) (%s): %v", c.Name, s.Email, err)
continue continue
} }
// Send the message. // Send the message.
r.msgQueue <- Message{Campaign: c, r.msgQueue <- m
Subscriber: s,
to: to,
body: body}
} }
return true, nil return true, nil
@ -263,21 +288,40 @@ func (r *Runner) processExhaustedCampaign(c *models.Campaign) error {
return nil return nil
} }
// makeMessage prepares a campaign message for a subscriber and returns // Render takes a Message, executes its pre-compiled Campaign.Tpl
// the 'to' address and the body. // and applies the resultant bytes to Message.body to be used in messages.
func (r *Runner) makeMessage(c *models.Campaign, s *models.Subscriber) (string, []byte, error) { func (m *Message) Render() error {
// Render the message body. out := bytes.Buffer{}
var ( if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil {
out = bytes.Buffer{} return err
tplMsg = Message{Campaign: c, }
Subscriber: s, m.Body = out.Bytes()
UnsubscribeURL: fmt.Sprintf(r.cfg.UnsubscribeURL, c.UUID, s.UUID)} return nil
) }
if err := c.Tpl.ExecuteTemplate(&out, BaseTPL, tplMsg); err != nil {
return "", nil, err // trackLink register a URL and return its UUID to be used in message templates
// for tracking links.
func (r *Runner) trackLink(url, campUUID, subUUID string) string {
r.linksMutex.RLock()
if uu, ok := r.links[url]; ok {
return uu
}
r.linksMutex.RUnlock()
// Register link.
uu, err := r.src.CreateLink(url)
if err != nil {
r.logger.Printf("error registering tracking for link '%s': %v", url, err)
// If the registration fails, fail over to the original URL.
return url
} }
return s.Email, out.Bytes(), nil r.linksMutex.Lock()
r.links[url] = uu
r.linksMutex.Unlock()
return fmt.Sprintf(r.cfg.LinkTrackURL, uu, campUUID, subUUID)
} }
// CompileMessageTemplate takes a base template body string and a child (message) template // CompileMessageTemplate takes a base template body string and a child (message) template

View File

@ -3,6 +3,7 @@ package main
import ( import (
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
"github.com/lib/pq" "github.com/lib/pq"
uuid "github.com/satori/go.uuid"
) )
// runnerDB implements runner.DataSource over the primary // runnerDB implements runner.DataSource over the primary
@ -58,3 +59,15 @@ func (r *runnerDB) FinishCampaign(campID int) error {
_, err := r.queries.UpdateCampaignStatus.Exec(campID, models.CampaignStatusFinished) _, err := r.queries.UpdateCampaignStatus.Exec(campID, models.CampaignStatusFinished)
return err return err
} }
// CreateLink registers a URL with a UUID for tracking clicks and returns the UUID.
func (r *runnerDB) CreateLink(url string) (string, error) {
// Create a new UUID for the URL. If the URL already exists in the DB
// the UUID in the database is returned.
var uu string
if err := r.queries.CreateLink.Get(&uu, uuid.NewV4(), url); err != nil {
return "", err
}
return uu, nil
}

View File

@ -128,7 +128,6 @@ CREATE UNIQUE INDEX ON campaign_lists (campaign_id, list_id);
DROP TABLE IF EXISTS campaign_views CASCADE; DROP TABLE IF EXISTS campaign_views CASCADE;
CREATE TABLE campaign_views ( CREATE TABLE campaign_views (
id SERIAL PRIMARY KEY,
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE, campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Subscribers may be deleted, but the link counts should remain. -- Subscribers may be deleted, but the link counts should remain.
@ -153,15 +152,14 @@ DROP TABLE IF EXISTS links CASCADE;
CREATE TABLE links ( CREATE TABLE links (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
uuid uuid NOT NULL UNIQUE, uuid uuid NOT NULL UNIQUE,
url TEXT NOT NULL, url TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
DROP TABLE IF EXISTS link_clicks CASCADE; DROP TABLE IF EXISTS link_clicks CASCADE;
CREATE TABLE link_clicks ( CREATE TABLE link_clicks (
id SERIAL PRIMARY KEY,
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE, campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE,
link_id INTEGER NULL REFERENCES links(id) ON DELETE CASCADE ON UPDATE CASCADE, link_id INTEGER REFERENCES links(id) ON DELETE CASCADE ON UPDATE CASCADE,
-- Subscribers may be deleted, but the link counts should remain. -- Subscribers may be deleted, but the link counts should remain.
subscriber_id INTEGER NULL REFERENCES subscribers(id) ON DELETE SET NULL ON UPDATE CASCADE, subscriber_id INTEGER NULL REFERENCES subscribers(id) ON DELETE SET NULL ON UPDATE CASCADE,

View File

@ -76,7 +76,12 @@ func handlePreviewTemplate(c echo.Context) error {
tpls []models.Template tpls []models.Template
) )
if body == "" { if body != "" {
if strings.Count(body, tplTag) != 1 {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Template body should contain the %s placeholder exactly once", tplTag))
}
} else {
if id < 1 { if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
} }
@ -227,8 +232,8 @@ func validateTemplate(o models.Template) error {
return errors.New("invalid length for `name`") return errors.New("invalid length for `name`")
} }
if !strings.Contains(o.Body, tplTag) { if strings.Count(o.Body, tplTag) != 1 {
return fmt.Errorf("template body should contain the %s placeholder", tplTag) return fmt.Errorf("template body should contain the %s placeholder exactly once", tplTag)
} }
return nil return nil

View File

@ -245,3 +245,17 @@ func normalizeTags(tags []string) []string {
return out return out
} }
// makeErrorTpl takes error details and returns an errorTpl
// with the error details applied to be rendered in an HTML view.
func makeErrorTpl(pageTitle, heading, desc string) errorTpl {
if heading == "" {
heading = pageTitle
}
err := errorTpl{}
err.Title = pageTitle
err.ErrorTitle = heading
err.ErrorMessage = desc
return err
}