Add support for campaign view tracking with {{ TrackView }} pixel tag

This commit is contained in:
Kailash Nadh 2018-11-02 13:20:32 +05:30
parent c96de8d11f
commit 6c5cf0da7a
11 changed files with 97 additions and 4 deletions

View File

@ -125,7 +125,6 @@ class App extends React.PureComponent {
} }
function replaceParams (route, params) { function replaceParams (route, params) {
console.log(route, params)
// Replace :params in the URL with params in the array. // Replace :params in the URL with params in the array.
let uriParams = route.match(/:([a-z0-9\-_]+)/ig) let uriParams = route.match(/:([a-z0-9\-_]+)/ig)
if(uriParams && uriParams.length > 0) { if(uriParams && uriParams.length > 0) {

BIN
listmonk Executable file

Binary file not shown.

View File

@ -129,6 +129,7 @@ func registerHandlers(e *echo.Echo) {
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) e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect)
e.GET("/campaign/:campUUID/:subUUID/px.png", handleRegisterCampaignView)
// Static views. // Static views.
e.GET("/lists", handleIndexPage) e.GET("/lists", handleIndexPage)
@ -224,8 +225,12 @@ 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} // url.com/link/{campaign_uuid}/{subscriber_uuid}/{link_uuid}
LinkTrackURL: fmt.Sprintf("%s/link/%%s/%%s/%%s", app.Constants.RootURL), LinkTrackURL: fmt.Sprintf("%s/link/%%s/%%s/%%s", app.Constants.RootURL),
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL),
}, newRunnerDB(q), logger) }, newRunnerDB(q), logger)
app.Runner = r app.Runner = r

View File

@ -52,6 +52,9 @@ const (
var ( var (
regexpLinkTag = regexp.MustCompile(`{{(\s+)?TrackLink\s+?"(.+?)"(\s+)?}}`) regexpLinkTag = regexp.MustCompile(`{{(\s+)?TrackLink\s+?"(.+?)"(\s+)?}}`)
regexpLinkTagReplace = `{{ TrackLink "$2" .Campaign.UUID .Subscriber.UUID }}` regexpLinkTagReplace = `{{ TrackLink "$2" .Campaign.UUID .Subscriber.UUID }}`
regexpViewTag = regexp.MustCompile(`{{(\s+)?TrackView(\s+)?}}`)
regexpViewTagReplace = `{{ TrackView .Campaign.UUID .Subscriber.UUID }}`
) )
// Base holds common fields shared across models. // Base holds common fields shared across models.
@ -208,6 +211,7 @@ func (s SubscriberAttribs) Scan(src interface{}) error {
func (c *Campaign) CompileTemplate(f template.FuncMap) error { func (c *Campaign) CompileTemplate(f template.FuncMap) error {
// Compile the base template. // Compile the base template.
t := regexpLinkTag.ReplaceAllString(c.TemplateBody, regexpLinkTagReplace) t := regexpLinkTag.ReplaceAllString(c.TemplateBody, regexpLinkTagReplace)
t = regexpViewTag.ReplaceAllString(c.TemplateBody, regexpViewTagReplace)
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(t) baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(t)
if err != nil { if err != nil {
return fmt.Errorf("error compiling base template: %v", err) return fmt.Errorf("error compiling base template: %v", err)
@ -215,6 +219,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
// Compile the campaign message. // Compile the campaign message.
t = regexpLinkTag.ReplaceAllString(c.Body, regexpLinkTagReplace) t = regexpLinkTag.ReplaceAllString(c.Body, regexpLinkTagReplace)
t = regexpViewTag.ReplaceAllString(c.Body, regexpViewTagReplace)
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(t) msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(t)
if err != nil { if err != nil {
return fmt.Errorf("error compiling message: %v", err) return fmt.Errorf("error compiling message: %v", err)

View File

@ -1,7 +1,10 @@
package main package main
import ( import (
"bytes"
"html/template" "html/template"
"image"
"image/png"
"io" "io"
"net/http" "net/http"
"regexp" "regexp"
@ -33,7 +36,10 @@ type errorTpl struct {
ErrorMessage string ErrorMessage string
} }
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}$")
pixelPNG = drawTransparentImage(3, 14)
)
// Render executes and renders a template for echo. // 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 {
@ -109,3 +115,34 @@ func handleLinkRedirect(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, url) return c.Redirect(http.StatusTemporaryRedirect, url)
} }
// handleRegisterCampaignView registers a campaign view which comes in
// the form of an pixel image request. Regardless of errors, this handler
// should always render the pixel image bytes.
func handleRegisterCampaignView(c echo.Context) error {
var (
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
)
if regexValidUUID.MatchString(campUUID) &&
regexValidUUID.MatchString(subUUID) {
if _, err := app.Queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
app.Logger.Printf("error registering campaign view: %s", err)
}
}
return c.Blob(http.StatusOK, "image/png", pixelPNG)
}
// drawTransparentImage draws a transparent PNG of given dimensions
// and returns the PNG bytes.
func drawTransparentImage(h, w int) []byte {
var (
img = image.NewRGBA(image.Rect(0, 0, w, h))
out = &bytes.Buffer{}
)
png.Encode(out, img)
return out.Bytes()
}

View File

@ -36,6 +36,7 @@ type Queries struct {
UpdateCampaign *sqlx.Stmt `query:"update-campaign"` UpdateCampaign *sqlx.Stmt `query:"update-campaign"`
UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"` UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"`
UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"` UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"`
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"` DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
CreateUser *sqlx.Stmt `query:"create-user"` CreateUser *sqlx.Stmt `query:"create-user"`

View File

@ -288,6 +288,15 @@ UPDATE campaigns SET status=$2, updated_at=NOW() WHERE id = $1;
-- name: delete-campaign -- name: delete-campaign
DELETE FROM campaigns WHERE id=$1 AND (status = 'draft' OR status = 'scheduled'); DELETE FROM campaigns WHERE id=$1 AND (status = 'draft' OR status = 'scheduled');
-- name: register-campaign-view
WITH view AS (
SELECT campaigns.id as campaign_id, subscribers.id AS subscriber_id FROM campaigns
LEFT JOIN subscribers ON (subscribers.uuid = $2)
WHERE campaigns.uuid = $1
)
INSERT INTO campaign_views (campaign_id, subscriber_id)
VALUES((SELECT campaign_id FROM view), (SELECT subscriber_id FROM view));
-- users -- users
-- name: get-users -- name: get-users
SELECT * FROM users WHERE $1 = 0 OR id = $1 OFFSET $2 LIMIT $3; SELECT * FROM users WHERE $1 = 0 OR id = $1 OFFSET $2 LIMIT $3;

View File

@ -70,6 +70,7 @@ type Config struct {
Concurrency int Concurrency int
LinkTrackURL string LinkTrackURL string
UnsubscribeURL string UnsubscribeURL string
ViewTrackURL string
} }
// New returns a new instance of Mailer. // New returns a new instance of Mailer.
@ -322,6 +323,10 @@ func (r *Runner) TemplateFuncs(c *models.Campaign) template.FuncMap {
"TrackLink": func(url, campUUID, subUUID string) string { "TrackLink": func(url, campUUID, subUUID string) string {
return r.trackLink(url, campUUID, subUUID) return r.trackLink(url, campUUID, subUUID)
}, },
"TrackView": func(campUUID, subUUID string) template.HTML {
return template.HTML(fmt.Sprintf(`<img src="%s" alt="campaign" />`,
fmt.Sprintf(r.cfg.ViewTrackURL, campUUID, subUUID)))
},
"FirstName": func(name string) string { "FirstName": func(name string) string {
for _, s := range strings.Split(name, " ") { for _, s := range strings.Split(name, " ") {
if len(s) > 2 { if len(s) > 2 {

View File

@ -128,9 +128,9 @@ 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 (
campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE, campaign_id INTEGER NOT NULL 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 view 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,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );

25
stats.sql Normal file
View File

@ -0,0 +1,25 @@
-- WITH l AS (
-- SELECT type, COUNT(id) AS count FROM lists GROUP BY type
-- ),
-- subs AS (
-- SELECT status, COUNT(id) AS count FROM subscribers GROUP by status
-- ),
-- subscrips AS (
-- SELECT status, COUNT(subscriber_id) AS count FROM subscriber_lists GROUP by status
-- ),
-- orphans AS (
-- SELECT COUNT(id) AS count FROM subscribers LEFT JOIN subscriber_lists ON (subscribers.id = subscriber_lists.subscriber_id)
-- WHERE subscriber_lists.subscriber_id IS NULL
-- ),
-- camps AS (
-- SELECT status, COUNT(id) AS count FROM campaigns GROUP by status
-- )
-- SELECT t3.*, t5.* FROM l t1
-- LEFT JOIN LATERAL (
-- SELECT JSON_AGG(t2.*) AS lists
-- FROM (SELECT * FROM l) t2
-- ) t3 ON TRUE
-- LEFT JOIN LATERAL (
-- SELECT JSON_AGG(t4.*) AS subs
-- FROM (SELECT * FROM subs) t4
-- ) t5 ON TRUE;

7
todo Normal file
View File

@ -0,0 +1,7 @@
- Add quote support to Quill link feature so that Track function can be inserted
- Make {{ template }} a Regex check to account for spaces
- Clicking on "all subscribers" from a list subscriber view doesn't do anything
- Add css inliner
- Duplicate mails to subscribers in multiple lists under one campaign?
- HTML syntax highlighting