Add support for campaign view tracking with {{ TrackView }} pixel tag
This commit is contained in:
parent
c96de8d11f
commit
6c5cf0da7a
11 changed files with 97 additions and 4 deletions
|
@ -125,7 +125,6 @@ class App extends React.PureComponent {
|
|||
}
|
||||
|
||||
function replaceParams (route, params) {
|
||||
console.log(route, params)
|
||||
// Replace :params in the URL with params in the array.
|
||||
let uriParams = route.match(/:([a-z0-9\-_]+)/ig)
|
||||
if(uriParams && uriParams.length > 0) {
|
||||
|
|
BIN
listmonk
Executable file
BIN
listmonk
Executable file
Binary file not shown.
5
main.go
5
main.go
|
@ -129,6 +129,7 @@ func registerHandlers(e *echo.Echo) {
|
|||
e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
|
||||
e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage)
|
||||
e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect)
|
||||
e.GET("/campaign/:campUUID/:subUUID/px.png", handleRegisterCampaignView)
|
||||
|
||||
// Static views.
|
||||
e.GET("/lists", handleIndexPage)
|
||||
|
@ -224,8 +225,12 @@ func main() {
|
|||
|
||||
// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
|
||||
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),
|
||||
|
||||
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
|
||||
ViewTrackURL: fmt.Sprintf("%s/campaign/%%s/%%s/px.png", app.Constants.RootURL),
|
||||
}, newRunnerDB(q), logger)
|
||||
app.Runner = r
|
||||
|
||||
|
|
|
@ -52,6 +52,9 @@ const (
|
|||
var (
|
||||
regexpLinkTag = regexp.MustCompile(`{{(\s+)?TrackLink\s+?"(.+?)"(\s+)?}}`)
|
||||
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.
|
||||
|
@ -208,6 +211,7 @@ func (s SubscriberAttribs) Scan(src interface{}) error {
|
|||
func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
||||
// Compile the base template.
|
||||
t := regexpLinkTag.ReplaceAllString(c.TemplateBody, regexpLinkTagReplace)
|
||||
t = regexpViewTag.ReplaceAllString(c.TemplateBody, regexpViewTagReplace)
|
||||
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(t)
|
||||
if err != nil {
|
||||
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.
|
||||
t = regexpLinkTag.ReplaceAllString(c.Body, regexpLinkTagReplace)
|
||||
t = regexpViewTag.ReplaceAllString(c.Body, regexpViewTagReplace)
|
||||
msgTpl, err := template.New(ContentTpl).Funcs(f).Parse(t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling message: %v", err)
|
||||
|
|
39
public.go
39
public.go
|
@ -1,7 +1,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
@ -33,7 +36,10 @@ type errorTpl struct {
|
|||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ type Queries struct {
|
|||
UpdateCampaign *sqlx.Stmt `query:"update-campaign"`
|
||||
UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"`
|
||||
UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"`
|
||||
RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"`
|
||||
DeleteCampaign *sqlx.Stmt `query:"delete-campaign"`
|
||||
|
||||
CreateUser *sqlx.Stmt `query:"create-user"`
|
||||
|
|
|
@ -288,6 +288,15 @@ UPDATE campaigns SET status=$2, updated_at=NOW() WHERE id = $1;
|
|||
-- name: delete-campaign
|
||||
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
|
||||
-- name: get-users
|
||||
SELECT * FROM users WHERE $1 = 0 OR id = $1 OFFSET $2 LIMIT $3;
|
||||
|
|
|
@ -70,6 +70,7 @@ type Config struct {
|
|||
Concurrency int
|
||||
LinkTrackURL string
|
||||
UnsubscribeURL string
|
||||
ViewTrackURL string
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
for _, s := range strings.Split(name, " ") {
|
||||
if len(s) > 2 {
|
||||
|
|
|
@ -128,9 +128,9 @@ CREATE UNIQUE INDEX ON campaign_lists (campaign_id, list_id);
|
|||
|
||||
DROP TABLE IF EXISTS campaign_views CASCADE;
|
||||
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,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
|
25
stats.sql
Normal file
25
stats.sql
Normal 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
7
todo
Normal 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
|
Loading…
Reference in a new issue