Skip to content

Commit

Permalink
Merge branch 'privacy'
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Jul 21, 2019
2 parents 794cb3d + 3cd8b86 commit b63b31e
Show file tree
Hide file tree
Showing 23 changed files with 783 additions and 223 deletions.
1 change: 0 additions & 1 deletion README.md
Expand Up @@ -49,7 +49,6 @@ Alternatively, to run a demo of listmonk, you can quickly spin up a container `d
- DB migrations
- Bounce tracking
- User auth, management, permissions
- Privacy features for subscribers (Download and wipe all tracking data)
- Ability to write raw campaign logs to a target
- Analytics views and reports
- Make Ant design UI components responsive
Expand Down
2 changes: 1 addition & 1 deletion campaigns.go
Expand Up @@ -509,7 +509,7 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
fmt.Sprintf("Error rendering message: %v", err))
}

if err := app.Messenger.Push(camp.FromEmail, []string{sub.Email}, camp.Subject, m.Body); err != nil {
if err := app.Messenger.Push(camp.FromEmail, []string{sub.Email}, camp.Subject, m.Body, nil); err != nil {
return err
}

Expand Down
25 changes: 23 additions & 2 deletions config.toml.sample
Expand Up @@ -44,6 +44,29 @@ concurrency = 100
max_send_errors = 1000


[privacy]
# Allow subscribers to unsubscribe from all mailing lists and mark themselves
# as blacklisted?
allow_blacklist = false

# Allow subscribers to export data recorded on them?
allow_export = false

# Items to include in the data export.
# profile Subscriber's profile including custom attributes
# subscriptions Subscriber's subscription lists (private list names are masked)
# campaign_views Campaigns the subscriber has viewed and the view counts
# link_clicks Links that the subscriber has clicked and the click counts
exportable = ["profile", "subscriptions", "campaign_views", "link_clicks"]

# Allow subscribers to delete themselves from the database?
# This deletes the subscriber and all their subscriptions.
# Their association to campaign views and link clicks are also
# removed while views and click counts remain (with no subscriber
# associated to them) so that stats and analytics aren't affected.
allow_wipe = false


# Database.
[db]
host = "demo-db"
Expand All @@ -53,8 +76,6 @@ password = "listmonk"
database = "listmonk"
ssl_mode = "disable"

# TQekh4quVgGc3HQ


# SMTP servers.
[smtp]
Expand Down
18 changes: 9 additions & 9 deletions email-templates/default.tpl
@@ -1,8 +1,8 @@
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<base target="_blank">

<style>
Expand Down Expand Up @@ -56,16 +56,16 @@
}
</style>
</head>
<body style="background-color: #F0F1F3;">
<div class="gutter">&nbsp;</div>
<div class="wrap">
<body style="background-color: #F0F1F3;font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;font-size: 15px;line-height: 26px;margin: 0;color: #444;">
<div class="gutter" style="padding: 30px;">&nbsp;</div>
<div class="wrap" style="background-color: #fff;padding: 30px;max-width: 525px;margin: 0 auto;border-radius: 5px;">
{{ template "content" . }}
</div>

<div class="footer">
<p>Don't want to receive these e-mails? <a href="{{ .UnsubscribeURL }}">Unsubscribe</a></p>
<p>Powered by <a href="https://listmonk.app" target="_blank">listmonk</a></p>
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
<p>Don't want to receive these e-mails? <a href="{{ .UnsubscribeURL }}" style="color: #888;">Unsubscribe</a></p>
<p>Powered by <a href="https://listmonk.app" target="_blank" style="color: #888;">listmonk</a></p>
</div>
<div class="gutter">&nbsp;{{ TrackView }}</div>
<div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>
</body>
</html>
9 changes: 9 additions & 0 deletions email-templates/subscriber-data.html
@@ -0,0 +1,9 @@
{{ define "subscriber-data" }}
{{ template "header" . }}
<h2>Your data</h2>
<p>
A copy of all data recorded on you is attached as a file in the JSON format.
It can be viewed in a text editor.
</p>
{{ template "footer" }}
{{ end }}
15 changes: 13 additions & 2 deletions frontend/src/Subscriber.js
Expand Up @@ -7,6 +7,8 @@ import {
Select,
Button,
Tag,
Tooltip,
Icon,
Spin,
Popconfirm,
notification
Expand Down Expand Up @@ -350,7 +352,7 @@ class Subscriber extends React.PureComponent {
<section className="content">
<header className="header">
<Row>
<Col span={20}>
<Col span={22}>
{!this.state.record.id && <h1>Add subscriber</h1>}
{this.state.record.id && (
<div>
Expand All @@ -372,7 +374,16 @@ class Subscriber extends React.PureComponent {
</div>
)}
</Col>
<Col span={2} />
<Col span={2} className="right">
<Tooltip title="Export data" placement="top">
<a
role="button"
href={"/api/subscribers/" + this.state.record.id + "/export"}
>
<Icon type="export" />
</a>
</Tooltip>
</Col>
</Row>
</header>
<div>
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Expand Up @@ -7,8 +7,9 @@ require (
github.com/jmoiron/sqlx v1.2.0
github.com/jordan-wright/email v0.0.0-20181027021455-480bedc4908b
github.com/knadh/goyesql v2.0.0+incompatible
github.com/knadh/koanf v0.4.3
github.com/knadh/koanf v0.4.4
github.com/knadh/stuffbin v1.0.0
github.com/kr/pretty v0.1.0 // indirect
github.com/labstack/echo v3.3.10+incompatible
github.com/labstack/gommon v0.2.7 // indirect
github.com/lib/pq v1.0.0
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Expand Up @@ -28,10 +28,17 @@ github.com/knadh/koanf v0.4.2 h1:A/bb9+eRoHHHQ57O6y66vzRCYui915CK3FdDYzNs56Q=
github.com/knadh/koanf v0.4.2/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/koanf v0.4.3 h1:aeCEnL10SVOIxnhhS3FeFtfvzC3RBphdhhrESE9qfCI=
github.com/knadh/koanf v0.4.3/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/koanf v0.4.4 h1:Pg+eR7wuJtCGHLeip31K20eJojjZ3lXE8ILQQGj2PTM=
github.com/knadh/koanf v0.4.4/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48 h1:lRb28d0+iiVwqF7Li25IJXjNRaVCQPH6n/fHwk9Qo+E=
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48/go.mod h1:afUOPBWr6bZ09aS3wbSOqXVGaO6rKcyvXYTcuG9LYpI=
github.com/knadh/stuffbin v1.0.0 h1:NQon6PTpLXies4bRFhS3VpLCf6y+jn6YVXU3i2wPQ+M=
github.com/knadh/stuffbin v1.0.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.7 h1:2qOPq/twXDrQ6ooBGrn3mrmVOC+biLlatwgIu8lbzRM=
Expand Down Expand Up @@ -75,6 +82,7 @@ google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO50
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=
Expand Down
85 changes: 48 additions & 37 deletions handlers.go
@@ -1,13 +1,10 @@
package main

import (
"encoding/json"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/asaskevich/govalidator"

"github.com/labstack/echo"
)
Expand Down Expand Up @@ -38,13 +35,16 @@ type pagination struct {
Limit int `json:"limit"`
}

var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")

// registerHandlers registers HTTP handlers.
func registerHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage)
e.GET("/api/config.js", handleGetConfigScript)
e.GET("/api/dashboard/stats", handleGetDashboardStats)

e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
e.POST("/api/subscribers", handleCreateSubscriber)
e.PUT("/api/subscribers/:id", handleUpdateSubscriber)
e.PUT("/api/subscribers/blacklist", handleBlacklistSubscribers)
Expand All @@ -59,7 +59,6 @@ func registerHandlers(e *echo.Echo) {
e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
e.PUT("/api/subscribers/query/blacklist", handleBlacklistSubscribersByQuery)
e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)

e.GET("/api/subscribers", handleQuerySubscribers)

e.GET("/api/import/subscribers", handleGetImportSubscribers)
Expand Down Expand Up @@ -98,10 +97,18 @@ func registerHandlers(e *echo.Echo) {
e.DELETE("/api/templates/:id", handleDeleteTemplate)

// Subscriber facing views.
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)
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
"subUUID"))
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
"subUUID"))
e.GET("/link/:linkUUID/:campUUID/:subUUID", validateUUID(handleLinkRedirect,
"linkUUID", "campUUID", "subUUID"))
e.GET("/campaign/:campUUID/:subUUID/px.png", validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID"))

// Static views.
e.GET("/lists", handleIndexPage)
Expand Down Expand Up @@ -129,40 +136,44 @@ func handleIndexPage(c echo.Context) error {
return c.String(http.StatusOK, string(b))
}

// makeAttribsBlob takes a list of keys and values and creates
// a JSON map out of them.
func makeAttribsBlob(keys []string, vals []string) ([]byte, bool) {
attribs := make(map[string]interface{})
for i, key := range keys {
// validateUUID middleware validates the UUID string format for a given set of params.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
for _, p := range params {
if !reUUID.MatchString(c.Param(p)) {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Invalid request", "",
`One or more UUIDs in the request are invalid.`))
}
}
return next(c)
}
}

// subscriberExists middleware checks if a subscriber exists given the UUID
// param in a request.
func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
s = vals[i]
val interface{}
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)

// Try to detect common JSON types.
if govalidator.IsFloat(s) {
val, _ = strconv.ParseFloat(s, 64)
} else if govalidator.IsInt(s) {
val, _ = strconv.ParseInt(s, 10, 64)
} else {
ls := strings.ToLower(s)
if ls == "true" || ls == "false" {
val, _ = strconv.ParseBool(ls)
} else {
// It's a string.
val = s
}
var exists bool
if err := app.Queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
app.Logger.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error", "",
`Error processing request. Please retry.`))
}

attribs[key] = val
}

if len(attribs) > 0 {
j, _ := json.Marshal(attribs)
return j, true
if !exists {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Not found", "",
`Subscription not found.`))
}
return next(c)
}

return nil, false
}

// getPagination takes form values and extracts pagination values from it.
Expand Down
27 changes: 19 additions & 8 deletions main.go
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/knadh/goyesql"
"github.com/knadh/koanf"
"github.com/knadh/koanf/maps"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
Expand All @@ -25,13 +26,21 @@ import (
)

type constants struct {
RootURL string `koanf:"root"`
LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"`
UploadPath string `koanf:"upload_path"`
UploadURI string `koanf:"upload_uri"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
RootURL string `koanf:"root"`
LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"`
UploadPath string `koanf:"upload_path"`
UploadURI string `koanf:"upload_uri"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
Privacy privacyOptions `koanf:"privacy"`
}

type privacyOptions struct {
AllowBlacklist bool `koanf:"allow_blacklist"`
AllowExport bool `koanf:"allow_export"`
AllowWipe bool `koanf:"allow_wipe"`
Exportable map[string]bool `koanf:"-"`
}

// App contains the "global" components that are
Expand Down Expand Up @@ -183,9 +192,11 @@ func main() {

var c constants
ko.Unmarshal("app", &c)
ko.Unmarshal("privacy", &c.Privacy)
c.RootURL = strings.TrimRight(c.RootURL, "/")
c.UploadURI = filepath.Clean(c.UploadURI)
c.UploadPath = filepath.Clean(c.UploadPath)
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))

// Initialize the static file system into which all
// required static assets (.sql, .js files etc.) are loaded.
Expand Down Expand Up @@ -253,7 +264,7 @@ func main() {
FromEmail: app.Constants.FromEmail,

// url.com/unsubscribe/{campaign_uuid}/{subscriber_uuid}
UnsubscribeURL: fmt.Sprintf("%s/unsubscribe/%%s/%%s", app.Constants.RootURL),
UnsubscribeURL: fmt.Sprintf("%s/subscription/%%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),
Expand Down
2 changes: 1 addition & 1 deletion manager/manager.go
Expand Up @@ -238,7 +238,7 @@ func (m *Manager) SpawnWorkers() {
msg.from,
[]string{msg.to},
msg.Campaign.Subject,
msg.Body)
msg.Body, nil)
if err != nil {
m.logger.Printf("error sending message in campaign %s: %v",
msg.Campaign.Name, err)
Expand Down

0 comments on commit b63b31e

Please sign in to comment.