Fix merge conflict
This commit is contained in:
commit
39ba75b420
27
campaigns.go
27
campaigns.go
|
@ -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)
|
||||||
|
|
|
@ -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 & import</Button>
|
<Button type="primary" htmlType="submit"><Icon type="upload" /> Upload & 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>
|
||||||
|
|
||||||
|
|
3
main.go
3
main.go
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
71
public.go
71
public.go
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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" .}}
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
12
queries.sql
12
queries.sql
|
@ -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))
|
||||||
|
|
112
runner/runner.go
112
runner/runner.go
|
@ -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
|
||||||
|
|
13
runner_db.go
13
runner_db.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
11
templates.go
11
templates.go
|
@ -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
|
||||||
|
|
14
utils.go
14
utils.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue