瀏覽代碼

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

Kailash Nadh 6 年之前
父節點
當前提交
6c5cf0da7a
共有 11 個文件被更改,包括 97 次插入4 次删除
  1. 0 1
      frontend/my/src/App.js
  2. 二進制
      listmonk
  3. 5 0
      main.go
  4. 5 0
      models/models.go
  5. 38 1
      public.go
  6. 1 0
      queries.go
  7. 9 0
      queries.sql
  8. 5 0
      runner/runner.go
  9. 2 2
      schema.sql
  10. 25 0
      stats.sql
  11. 7 0
      todo

+ 0 - 1
frontend/my/src/App.js

@@ -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) {

二進制
listmonk


+ 5 - 0
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
 

+ 5 - 0
models/models.go

@@ -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)

+ 38 - 1
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()
+}

+ 1 - 0
queries.go

@@ -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"`

+ 9 - 0
queries.sql

@@ -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;

+ 5 - 0
runner/runner.go

@@ -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 {

+ 2 - 2
schema.sql

@@ -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 - 0
stats.sql

@@ -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 - 0
todo

@@ -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