diff --git a/frontend/my/src/App.js b/frontend/my/src/App.js index 81c86e1..59c2a64 100644 --- a/frontend/my/src/App.js +++ b/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) { diff --git a/listmonk b/listmonk new file mode 100755 index 0000000..2c9c3f3 Binary files /dev/null and b/listmonk differ diff --git a/main.go b/main.go index f799af7..c8d8fd2 100644 --- a/main.go +++ b/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 diff --git a/models/models.go b/models/models.go index fca6a39..b121267 100644 --- a/models/models.go +++ b/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) diff --git a/public.go b/public.go index ef316e4..aabddde 100644 --- a/public.go +++ b/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() +} diff --git a/queries.go b/queries.go index 67b18ee..7d71ca0 100644 --- a/queries.go +++ b/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"` diff --git a/queries.sql b/queries.sql index e93da46..988ac34 100644 --- a/queries.sql +++ b/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; diff --git a/runner/runner.go b/runner/runner.go index bd9fca1..13bb3f6 100644 --- a/runner/runner.go +++ b/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(`campaign`, + fmt.Sprintf(r.cfg.ViewTrackURL, campUUID, subUUID))) + }, "FirstName": func(name string) string { for _, s := range strings.Split(name, " ") { if len(s) > 2 { diff --git a/schema.sql b/schema.sql index d98579a..37d3bf4 100644 --- a/schema.sql +++ b/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() ); diff --git a/stats.sql b/stats.sql new file mode 100644 index 0000000..1cca357 --- /dev/null +++ b/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; \ No newline at end of file diff --git a/todo b/todo new file mode 100644 index 0000000..f7bc3f1 --- /dev/null +++ b/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 \ No newline at end of file