Merge branch 'privacy'

This commit is contained in:
Kailash Nadh 2019-07-21 22:12:54 +05:30
commit b63b31ec52
23 changed files with 784 additions and 224 deletions

View File

@ -49,7 +49,6 @@ Alternatively, to run a demo of listmonk, you can quickly spin up a container `d
- DB migrations - DB migrations
- Bounce tracking - Bounce tracking
- User auth, management, permissions - User auth, management, permissions
- Privacy features for subscribers (Download and wipe all tracking data)
- Ability to write raw campaign logs to a target - Ability to write raw campaign logs to a target
- Analytics views and reports - Analytics views and reports
- Make Ant design UI components responsive - Make Ant design UI components responsive

View File

@ -509,7 +509,7 @@ func sendTestMessage(sub *models.Subscriber, camp *models.Campaign, app *App) er
fmt.Sprintf("Error rendering message: %v", err)) 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 return err
} }

View File

@ -44,6 +44,29 @@ concurrency = 100
max_send_errors = 1000 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. # Database.
[db] [db]
host = "demo-db" host = "demo-db"
@ -53,8 +76,6 @@ password = "listmonk"
database = "listmonk" database = "listmonk"
ssl_mode = "disable" ssl_mode = "disable"
# TQekh4quVgGc3HQ
# SMTP servers. # SMTP servers.
[smtp] [smtp]

View File

@ -1,8 +1,8 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <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 name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<base target="_blank"> <base target="_blank">
<style> <style>
@ -56,16 +56,16 @@
} }
</style> </style>
</head> </head>
<body style="background-color: #F0F1F3;"> <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">&nbsp;</div> <div class="gutter" style="padding: 30px;">&nbsp;</div>
<div class="wrap"> <div class="wrap" style="background-color: #fff;padding: 30px;max-width: 525px;margin: 0 auto;border-radius: 5px;">
{{ template "content" . }} {{ template "content" . }}
</div> </div>
<div class="footer"> <div class="footer" style="text-align: center;font-size: 12px;color: #888;">
<p>Don't want to receive these e-mails? <a href="{{ .UnsubscribeURL }}">Unsubscribe</a></p> <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">listmonk</a></p> <p>Powered by <a href="https://listmonk.app" target="_blank" style="color: #888;">listmonk</a></p>
</div> </div>
<div class="gutter">&nbsp;{{ TrackView }}</div> <div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>
</body> </body>
</html> </html>

View File

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

View File

@ -7,6 +7,8 @@ import {
Select, Select,
Button, Button,
Tag, Tag,
Tooltip,
Icon,
Spin, Spin,
Popconfirm, Popconfirm,
notification notification
@ -350,7 +352,7 @@ class Subscriber extends React.PureComponent {
<section className="content"> <section className="content">
<header className="header"> <header className="header">
<Row> <Row>
<Col span={20}> <Col span={22}>
{!this.state.record.id && <h1>Add subscriber</h1>} {!this.state.record.id && <h1>Add subscriber</h1>}
{this.state.record.id && ( {this.state.record.id && (
<div> <div>
@ -372,7 +374,16 @@ class Subscriber extends React.PureComponent {
</div> </div>
)} )}
</Col> </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> </Row>
</header> </header>
<div> <div>

3
go.mod
View File

@ -7,8 +7,9 @@ require (
github.com/jmoiron/sqlx v1.2.0 github.com/jmoiron/sqlx v1.2.0
github.com/jordan-wright/email v0.0.0-20181027021455-480bedc4908b github.com/jordan-wright/email v0.0.0-20181027021455-480bedc4908b
github.com/knadh/goyesql v2.0.0+incompatible 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/knadh/stuffbin v1.0.0
github.com/kr/pretty v0.1.0 // indirect
github.com/labstack/echo v3.3.10+incompatible github.com/labstack/echo v3.3.10+incompatible
github.com/labstack/gommon v0.2.7 // indirect github.com/labstack/gommon v0.2.7 // indirect
github.com/lib/pq v1.0.0 github.com/lib/pq v1.0.0

8
go.sum
View File

@ -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.2/go.mod h1:Qd5yvXN39ZzjoRJdXMKN2QqHzQKhSx/K8fU5gyn4LPs=
github.com/knadh/koanf v0.4.3 h1:aeCEnL10SVOIxnhhS3FeFtfvzC3RBphdhhrESE9qfCI= 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.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 h1:lRb28d0+iiVwqF7Li25IJXjNRaVCQPH6n/fHwk9Qo+E=
github.com/knadh/stuffbin v0.0.0-20190103171338-6379e949be48/go.mod h1:afUOPBWr6bZ09aS3wbSOqXVGaO6rKcyvXYTcuG9LYpI= 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 h1:NQon6PTpLXies4bRFhS3VpLCf6y+jn6YVXU3i2wPQ+M=
github.com/knadh/stuffbin v1.0.0/go.mod h1:yVCFaWaKPubSNibBsTAJ939q2ABHudJQxRWZWV5yh+4= 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 h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.7 h1:2qOPq/twXDrQ6ooBGrn3mrmVOC+biLlatwgIu8lbzRM= github.com/labstack/gommon v0.2.7 h1:2qOPq/twXDrQ6ooBGrn3mrmVOC+biLlatwgIu8lbzRM=
@ -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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/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 h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY= gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=

View File

@ -1,13 +1,10 @@
package main package main
import ( import (
"encoding/json"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo" "github.com/labstack/echo"
) )
@ -38,6 +35,8 @@ type pagination struct {
Limit int `json:"limit"` 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. // registerHandlers registers HTTP handlers.
func registerHandlers(e *echo.Echo) { func registerHandlers(e *echo.Echo) {
e.GET("/", handleIndexPage) e.GET("/", handleIndexPage)
@ -45,6 +44,7 @@ func registerHandlers(e *echo.Echo) {
e.GET("/api/dashboard/stats", handleGetDashboardStats) e.GET("/api/dashboard/stats", handleGetDashboardStats)
e.GET("/api/subscribers/:id", handleGetSubscriber) e.GET("/api/subscribers/:id", handleGetSubscriber)
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
e.POST("/api/subscribers", handleCreateSubscriber) e.POST("/api/subscribers", handleCreateSubscriber)
e.PUT("/api/subscribers/:id", handleUpdateSubscriber) e.PUT("/api/subscribers/:id", handleUpdateSubscriber)
e.PUT("/api/subscribers/blacklist", handleBlacklistSubscribers) e.PUT("/api/subscribers/blacklist", handleBlacklistSubscribers)
@ -59,7 +59,6 @@ func registerHandlers(e *echo.Echo) {
e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery) e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
e.PUT("/api/subscribers/query/blacklist", handleBlacklistSubscribersByQuery) e.PUT("/api/subscribers/query/blacklist", handleBlacklistSubscribersByQuery)
e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery) e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
e.GET("/api/subscribers", handleQuerySubscribers) e.GET("/api/subscribers", handleQuerySubscribers)
e.GET("/api/import/subscribers", handleGetImportSubscribers) e.GET("/api/import/subscribers", handleGetImportSubscribers)
@ -98,10 +97,18 @@ func registerHandlers(e *echo.Echo) {
e.DELETE("/api/templates/:id", handleDeleteTemplate) e.DELETE("/api/templates/:id", handleDeleteTemplate)
// Subscriber facing views. // Subscriber facing views.
e.GET("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage) e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
e.POST("/unsubscribe/:campUUID/:subUUID", handleUnsubscribePage) "campUUID", "subUUID"))
e.GET("/link/:linkUUID/:campUUID/:subUUID", handleLinkRedirect) e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
e.GET("/campaign/:campUUID/:subUUID/px.png", handleRegisterCampaignView) "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. // Static views.
e.GET("/lists", handleIndexPage) e.GET("/lists", handleIndexPage)
@ -129,40 +136,44 @@ func handleIndexPage(c echo.Context) error {
return c.String(http.StatusOK, string(b)) return c.String(http.StatusOK, string(b))
} }
// makeAttribsBlob takes a list of keys and values and creates // validateUUID middleware validates the UUID string format for a given set of params.
// a JSON map out of them. func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
func makeAttribsBlob(keys []string, vals []string) ([]byte, bool) { return func(c echo.Context) error {
attribs := make(map[string]interface{}) for _, p := range params {
for i, key := range keys { if !reUUID.MatchString(c.Param(p)) {
var ( return c.Render(http.StatusBadRequest, "message",
s = vals[i] makeMsgTpl("Invalid request", "",
val interface{} `One or more UUIDs in the request are invalid.`))
)
// 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
} }
} }
return next(c)
attribs[key] = val
} }
}
if len(attribs) > 0 { // subscriberExists middleware checks if a subscriber exists given the UUID
j, _ := json.Marshal(attribs) // param in a request.
return j, true func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
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.`))
}
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. // getPagination takes form values and extracts pagination values from it.

27
main.go
View File

@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/goyesql" "github.com/knadh/goyesql"
"github.com/knadh/koanf" "github.com/knadh/koanf"
"github.com/knadh/koanf/maps"
"github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/posflag"
@ -25,13 +26,21 @@ import (
) )
type constants struct { type constants struct {
RootURL string `koanf:"root"` RootURL string `koanf:"root"`
LogoURL string `koanf:"logo_url"` LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"` FaviconURL string `koanf:"favicon_url"`
UploadPath string `koanf:"upload_path"` UploadPath string `koanf:"upload_path"`
UploadURI string `koanf:"upload_uri"` UploadURI string `koanf:"upload_uri"`
FromEmail string `koanf:"from_email"` FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"` 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 // App contains the "global" components that are
@ -183,9 +192,11 @@ func main() {
var c constants var c constants
ko.Unmarshal("app", &c) ko.Unmarshal("app", &c)
ko.Unmarshal("privacy", &c.Privacy)
c.RootURL = strings.TrimRight(c.RootURL, "/") c.RootURL = strings.TrimRight(c.RootURL, "/")
c.UploadURI = filepath.Clean(c.UploadURI) c.UploadURI = filepath.Clean(c.UploadURI)
c.UploadPath = filepath.Clean(c.UploadPath) c.UploadPath = filepath.Clean(c.UploadPath)
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
// Initialize the static file system into which all // Initialize the static file system into which all
// required static assets (.sql, .js files etc.) are loaded. // required static assets (.sql, .js files etc.) are loaded.
@ -253,7 +264,7 @@ func main() {
FromEmail: app.Constants.FromEmail, FromEmail: app.Constants.FromEmail,
// 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/subscription/%%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),

View File

@ -238,7 +238,7 @@ func (m *Manager) SpawnWorkers() {
msg.from, msg.from,
[]string{msg.to}, []string{msg.to},
msg.Campaign.Subject, msg.Campaign.Subject,
msg.Body) msg.Body, nil)
if err != nil { if err != nil {
m.logger.Printf("error sending message in campaign %s: %v", m.logger.Printf("error sending message in campaign %s: %v",
msg.Campaign.Name, err) msg.Campaign.Name, err)

View File

@ -66,7 +66,7 @@ func (e *emailer) Name() string {
} }
// Push pushes a message to the server. // Push pushes a message to the server.
func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byte) error { func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byte, atts []*Attachment) error {
var key string var key string
// If there are more than one SMTP servers, send to a random // If there are more than one SMTP servers, send to a random
@ -77,12 +77,28 @@ func (e *emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
key = e.serverNames[0] key = e.serverNames[0]
} }
// Are there attachments?
var files []*email.Attachment
if atts != nil {
files = make([]*email.Attachment, 0, len(atts))
for _, f := range atts {
a := &email.Attachment{
Filename: f.Name,
Header: f.Header,
Content: make([]byte, len(f.Content)),
}
copy(a.Content, f.Content)
files = append(files, a)
}
}
srv := e.servers[key] srv := e.servers[key]
err := srv.mailer.Send(&email.Email{ err := srv.mailer.Send(&email.Email{
From: fromAddr, From: fromAddr,
To: toAddr, To: toAddr,
Subject: subject, Subject: subject,
HTML: m, HTML: m,
Attachments: files,
}, srv.SendTimeout) }, srv.SendTimeout)
return err return err

View File

@ -1,10 +1,34 @@
package messenger package messenger
import "net/textproto"
// Messenger is an interface for a generic messaging backend, // Messenger is an interface for a generic messaging backend,
// for instance, e-mail, SMS etc. // for instance, e-mail, SMS etc.
type Messenger interface { type Messenger interface {
Name() string Name() string
Push(fromAddr string, toAddr []string, subject string, message []byte) error Push(fromAddr string, toAddr []string, subject string, message []byte, atts []*Attachment) error
Flush() error Flush() error
} }
// Attachment represents a file or blob attachment that can be
// sent along with a message by a Messenger.
type Attachment struct {
Name string
Header textproto.MIMEHeader
Content []byte
}
// MakeAttachmentHeader is a helper function that returns a
// textproto.MIMEHeader tailored for attachments, primarily
// email. If no encoding is given, base64 is assumed.
func MakeAttachmentHeader(filename, encoding string) textproto.MIMEHeader {
if encoding == "" {
encoding = "base64"
}
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", "attachment; filename="+filename)
h.Set("Content-Type", "application/json; name=\""+filename+"\"")
h.Set("Content-Transfer-Encoding", "base64")
return h
}

View File

@ -22,7 +22,8 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
err = app.Messenger.Push(app.Constants.FromEmail, err = app.Messenger.Push(app.Constants.FromEmail,
app.Constants.NotifyEmails, app.Constants.NotifyEmails,
subject, subject,
b.Bytes()) b.Bytes(),
nil)
if err != nil { if err != nil {
app.Logger.Printf("error sending admin notification (%s): %v", subject, err) app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
return err return err
@ -30,3 +31,18 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
return nil return nil
} }
func getNotificationTemplate(tpl string, data map[string]interface{}, app *App) ([]byte, error) {
if data == nil {
data = make(map[string]interface{})
}
data["RootURL"] = app.Constants.RootURL
var b bytes.Buffer
err := app.NotifTpls.ExecuteTemplate(&b, tpl, data)
if err != nil {
return nil, err
}
return b.Bytes(), err
}

171
public.go
View File

@ -7,10 +7,11 @@ import (
"image/png" "image/png"
"io" "io"
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"github.com/knadh/listmonk/messenger"
"github.com/labstack/echo" "github.com/labstack/echo"
"github.com/lib/pq"
) )
// tplRenderer wraps a template.tplRenderer for echo. // tplRenderer wraps a template.tplRenderer for echo.
@ -37,19 +38,20 @@ type publicTpl struct {
type unsubTpl struct { type unsubTpl struct {
publicTpl publicTpl
Unsubscribe bool SubUUID string
Blacklist bool AllowBlacklist bool
AllowExport bool
AllowWipe bool
} }
type errorTpl struct { type msgTpl struct {
publicTpl publicTpl
ErrorTitle string MessageTitle string
ErrorMessage string Message string
} }
var ( 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)
pixelPNG = drawTransparentImage(3, 14)
) )
// Render executes and renders a template for echo. // Render executes and renders a template for echo.
@ -62,50 +64,42 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.
}) })
} }
// handleUnsubscribePage unsubscribes a subscriber and renders a view. // handleSubscriptionPage renders the subscription management page and
func handleUnsubscribePage(c echo.Context) error { // handles unsubscriptions.
func handleSubscriptionPage(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
campUUID = c.Param("campUUID") campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID") subUUID = c.Param("subUUID")
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe")) unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist")) blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
out = unsubTpl{}
out = unsubTpl{}
) )
out.Unsubscribe = unsub out.SubUUID = subUUID
out.Blacklist = blacklist
out.Title = "Unsubscribe from mailing list" out.Title = "Unsubscribe from mailing list"
out.AllowBlacklist = app.Constants.Privacy.AllowBlacklist
if !regexValidUUID.MatchString(campUUID) || out.AllowExport = app.Constants.Privacy.AllowExport
!regexValidUUID.MatchString(subUUID) { out.AllowWipe = app.Constants.Privacy.AllowWipe
return c.Render(http.StatusBadRequest, "error",
makeErrorTpl("Invalid request", "",
`The unsubscription request contains invalid IDs.
Please follow the correct link.`))
}
// Unsubscribe. // Unsubscribe.
if unsub { if unsub {
res, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist) // Is blacklisting allowed?
if err != nil { if !app.Constants.Privacy.AllowBlacklist {
app.Logger.Printf("Error unsubscribing : %v", err) blacklist = false
return echo.NewHTTPError(http.StatusBadRequest,
"There was an internal error while unsubscribing you.")
} }
if !blacklist { if _, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist); err != nil {
num, _ := res.RowsAffected() app.Logger.Printf("error unsubscribing: %v", err)
if num == 0 { return c.Render(http.StatusInternalServerError, "message",
return c.Render(http.StatusBadRequest, "error", makeMsgTpl("Error", "",
makeErrorTpl("Already unsubscribed", "", `Error processing request. Please retry.`))
`You are not subscribed to this mailing list.
You may have already unsubscribed.`))
}
} }
return c.Render(http.StatusOK, "message",
makeMsgTpl("Unsubscribed", "",
`You have been successfully unsubscribed.`))
} }
return c.Render(http.StatusOK, "unsubscribe", out) return c.Render(http.StatusOK, "subscription", out)
} }
// handleLinkRedirect handles link UUID to real link redirection. // handleLinkRedirect handles link UUID to real link redirection.
@ -116,18 +110,12 @@ func handleLinkRedirect(c echo.Context) error {
campUUID = c.Param("campUUID") campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID") subUUID = c.Param("subUUID")
) )
if !regexValidUUID.MatchString(linkUUID) ||
!regexValidUUID.MatchString(campUUID) ||
!regexValidUUID.MatchString(subUUID) {
return c.Render(http.StatusBadRequest, "error",
makeErrorTpl("Invalid link", "", "The link you clicked is invalid."))
}
var url string var url string
if err := app.Queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { if err := app.Queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
app.Logger.Printf("error fetching redirect link: %s", err) app.Logger.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, "error", return c.Render(http.StatusInternalServerError, "message",
makeErrorTpl("Error opening link", "", makeMsgTpl("Error opening link", "",
"There was an error opening the link. Please try later.")) "There was an error opening the link. Please try later."))
} }
@ -143,17 +131,100 @@ func handleRegisterCampaignView(c echo.Context) error {
campUUID = c.Param("campUUID") campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID") subUUID = c.Param("subUUID")
) )
if regexValidUUID.MatchString(campUUID) && if _, err := app.Queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
regexValidUUID.MatchString(subUUID) { app.Logger.Printf("error registering campaign view: %s", err)
if _, err := app.Queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
app.Logger.Printf("error registering campaign view: %s", err)
}
} }
c.Response().Header().Set("Cache-Control", "no-cache") c.Response().Header().Set("Cache-Control", "no-cache")
return c.Blob(http.StatusOK, "image/png", pixelPNG) return c.Blob(http.StatusOK, "image/png", pixelPNG)
} }
// handleSelfExportSubscriberData pulls the subscriber's profile,
// list subscriptions, campaign views and clicks and produces
// a JSON report. This is a privacy feature and depends on the
// configuration in app.Constants.Privacy.
func handleSelfExportSubscriberData(c echo.Context) error {
var (
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
// Is export allowed?
if !app.Constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Invalid request", "",
"The feature is not available."))
}
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
data, b, err := exportSubscriberData(0, subUUID, app.Constants.Privacy.Exportable, app)
if err != nil {
app.Logger.Printf("error exporting subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error processing request", "",
"There was an error processing your request. Please try later."))
}
// Send the data out to the subscriber as an atachment.
msg, err := getNotificationTemplate("subscriber-data", nil, app)
if err != nil {
app.Logger.Printf("error preparing subscriber data e-mail template: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error preparing data", "",
"There was an error preparing your data. Please try later."))
}
const fname = "profile.json"
if err := app.Messenger.Push(app.Constants.FromEmail,
[]string{data.Email},
"Your profile data",
msg,
[]*messenger.Attachment{
&messenger.Attachment{
Name: fname,
Content: b,
Header: messenger.MakeAttachmentHeader(fname, "base64"),
},
},
); err != nil {
app.Logger.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error e-mailing data", "",
"There was an error e-mailing your data. Please try later."))
}
return c.Render(http.StatusOK, "message",
makeMsgTpl("Data e-mailed", "",
`Your data has been e-mailed to you as an attachment.`))
}
// handleWipeSubscriberData allows a subscriber to self-delete their data. The
// profile and subscriptions are deleted, while the campaign_views and link
// clicks remain as orphan data unconnected to any subscriber.
func handleWipeSubscriberData(c echo.Context) error {
var (
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
// Is wiping allowed?
if !app.Constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, "message",
makeMsgTpl("Invalid request", "",
"The feature is not available."))
}
if _, err := app.Queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
app.Logger.Printf("error wiping subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, "message",
makeMsgTpl("Error processing request", "",
"There was an error processing your request. Please try later."))
}
return c.Render(http.StatusOK, "message",
makeMsgTpl("Data removed", "",
`Your subscriptions and all associated data has been removed.`))
}
// drawTransparentImage draws a transparent PNG of given dimensions // drawTransparentImage draws a transparent PNG of given dimensions
// and returns the PNG bytes. // and returns the PNG bytes.
func drawTransparentImage(h, w int) []byte { func drawTransparentImage(h, w int) []byte {

View File

@ -1,78 +1,273 @@
/* Flexit grid */ /* Flexit grid */
.container{position:relative;width:100%;max-width:960px;margin:0 auto;padding:0 10px;box-sizing:border-box}.row{box-sizing:border-box;display:flex;flex:0 1 auto;flex-flow:row wrap}.columns,.column{box-sizing:border-box;flex-grow:1;flex-shrink:1;flex-basis:1;margin:10px 0 10px 4%}.column:first-child,.columns:first-child{margin-left:0}.one{max-width:4.6666666667%}.two{max-width:13.3333333333%}.three{max-width:22%}.four{max-width:30.6666666667%}.five{max-width:39.3333333333%}.six{max-width:48%}.seven{max-width:56.6666666667%}.eight{max-width:65.3333333333%}.nine{max-width:74%}.ten{max-width:82.6666666667%}.eleven{max-width:91.3333333333%}.twelve{max-width:100%;margin-left:0}.column-offset-0{margin-left:0}.column-offset-1{margin-left:8.33333333%}.column-offset-2{margin-left:16.66666667%}.column-offset-3{margin-left:25%}.column-offset-4{margin-left:33.33333333%}.column-offset-5{margin-left:41.66666667%}.column-offset-6{margin-left:50%}.column-offset-7{margin-left:58.33333333%}.column-offset-8{margin-left:66.66666667%}.column-offset-9{margin-left:75%}.column-offset-10{margin-left:83.33333333%}.column-offset-11{margin-left:91.66666667%}.between{justify-content:space-between}.evenly{justify-content:space-evenly}.around{justify-content:space-around}.center{justify-content:center;text-align:center}.start{justify-content:flex-start}.end{justify-content:flex-end}.top{align-items:flex-start}.bottom{align-items:flex-end}.middle{align-items:center}.first{order:-1}.last{order:1}.vertical{flex-flow:column wrap}.row-align-center{align-items:center}.space-right{margin-right:10px}.space-left{margin-left:10px}.space-bottom{margin-bottom:10px}.space-top{margin-top:10px}@media screen and (max-width: 768px){.container{overflow:auto}.columns,.column{min-width:100%;margin:10px 0}.column-offset-0,.column-offset-1,.column-offset-2,.column-offset-3,.column-offset-4,.column-offset-5,.column-offset-6,.column-offset-7,.column-offset-8,.column-offset-9,.column-offset-10,.column-offset-11{margin:unset}}/*# sourceMappingURL=dist/flexit.min.css.map */ .container {
position: relative;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 10px;
box-sizing: border-box;
}
.row {
box-sizing: border-box;
display: flex;
flex: 0 1 auto;
flex-flow: row wrap;
}
.columns,
.column {
box-sizing: border-box;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 1;
margin: 10px 0 10px 4%;
}
.column:first-child,
.columns:first-child {
margin-left: 0;
}
.one {
max-width: 4.6666666667%;
}
.two {
max-width: 13.3333333333%;
}
.three {
max-width: 22%;
}
.four {
max-width: 30.6666666667%;
}
.five {
max-width: 39.3333333333%;
}
.six {
max-width: 48%;
}
.seven {
max-width: 56.6666666667%;
}
.eight {
max-width: 65.3333333333%;
}
.nine {
max-width: 74%;
}
.ten {
max-width: 82.6666666667%;
}
.eleven {
max-width: 91.3333333333%;
}
.twelve {
max-width: 100%;
margin-left: 0;
}
.column-offset-0 {
margin-left: 0;
}
.column-offset-1 {
margin-left: 8.33333333%;
}
.column-offset-2 {
margin-left: 16.66666667%;
}
.column-offset-3 {
margin-left: 25%;
}
.column-offset-4 {
margin-left: 33.33333333%;
}
.column-offset-5 {
margin-left: 41.66666667%;
}
.column-offset-6 {
margin-left: 50%;
}
.column-offset-7 {
margin-left: 58.33333333%;
}
.column-offset-8 {
margin-left: 66.66666667%;
}
.column-offset-9 {
margin-left: 75%;
}
.column-offset-10 {
margin-left: 83.33333333%;
}
.column-offset-11 {
margin-left: 91.66666667%;
}
.between {
justify-content: space-between;
}
.evenly {
justify-content: space-evenly;
}
.around {
justify-content: space-around;
}
.center {
justify-content: center;
text-align: center;
}
.start {
justify-content: flex-start;
}
.end {
justify-content: flex-end;
}
.top {
align-items: flex-start;
}
.bottom {
align-items: flex-end;
}
.middle {
align-items: center;
}
.first {
order: -1;
}
.last {
order: 1;
}
.vertical {
flex-flow: column wrap;
}
.row-align-center {
align-items: center;
}
.space-right {
margin-right: 10px;
}
.space-left {
margin-left: 10px;
}
.space-bottom {
margin-bottom: 10px;
}
.space-top {
margin-top: 10px;
}
@media screen and (max-width: 768px) {
.container {
overflow: auto;
}
.columns,
.column {
min-width: 100%;
margin: 10px 0;
}
.column-offset-0,
.column-offset-1,
.column-offset-2,
.column-offset-3,
.column-offset-4,
.column-offset-5,
.column-offset-6,
.column-offset-7,
.column-offset-8,
.column-offset-9,
.column-offset-10,
.column-offset-11 {
margin: unset;
}
} /*# sourceMappingURL=dist/flexit.min.css.map */
body { body {
background: #f9f9f9; background: #f9f9f9;
font-family: "Open Sans", "Helvetica Neue", sans-serif; font-family: "Open Sans", "Helvetica Neue", sans-serif;
font-size: 16px; font-size: 16px;
line-height: 28px; line-height: 28px;
color: #111; color: #111;
} }
a { a {
color: #000; color: #7f2aff;
} }
a:hover {
h1, h2, h3, h4 { color: #111;
font-weight: 400; }
label {
cursor: pointer;
}
h1,
h2,
h3,
h4 {
font-weight: 400;
}
section {
margin-bottom: 45px;
} }
.button { .button {
background: #7f2aff; background: #7f2aff;
padding: 10px 30px; padding: 10px 30px;
border-radius: 3px; border-radius: 3px;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
color: #ffff; color: #ffff;
display: inline-block; display: inline-block;
min-width: 150px;
}
.button:hover {
background: #333;
}
.button.button-outline {
background: #fff;
border: 1px solid #7f2aff;
color: #7f2aff;
}
.button.button-outline:hover {
background-color: #7f2aff;
color: #fff;
} }
.button:hover {
background: #333;
}
.button .button-outline {
background: transparent;
border: 1px solid #ddd;
color: #444'
}
.button .button-outline:hover {
color: #111;
}
.wrap { .wrap {
background: #fff; background: #fff;
margin-top: 60px; margin-top: 60px;
max-width: 600px; max-width: 600px;
padding: 45px; padding: 45px;
box-shadow: 2px 2px 0 #f3f3f3; box-shadow: 2px 2px 0 #f3f3f3;
border: 1px solid #eee; border: 1px solid #eee;
} }
.header { .header {
margin-bottom: 60px; margin-bottom: 60px;
}
.header .logo img {
width: auto;
max-width: 150px;
} }
.header .logo img {
width: auto;
max-width: 150px;
}
.unsub-all { .unsub-all {
margin-top: 30px; margin-top: 30px;
padding-top: 30px; padding-top: 30px;
border-top: 1px solid #eee; border-top: 1px solid #eee;
} }
.footer { .footer {
text-align: center; text-align: center;
color: #aaa; color: #aaa;
font-size: 0.775em; font-size: 0.775em;
margin-top: 30px; margin-top: 30px;
margin-bottom: 30px; margin-bottom: 30px;
}
.footer a {
color: #aaa;
text-decoration: none;
}
.footer a:hover {
color: #111;
}
@media screen and (max-width: 650px) {
.wrap {
margin: 0;
}
.header {
margin-bottom: 30px;
}
} }
.footer a {
color: #aaa;
text-decoration: none;
}
.footer a:hover {
color: #111;
}

View File

@ -1,9 +1,9 @@
{{ define "error" }} {{ define "message" }}
{{ template "header" .}} {{ template "header" .}}
<h2>{{ .Data.ErrorTitle }}</h2> <h2>{{ .Data.Title }}</h2>
<div> <div>
{{ .Data.ErrorMessage }} {{ .Data.Message }}
</div> </div>
{{ template "footer" .}} {{ template "footer" .}}

View File

@ -0,0 +1,74 @@
{{ define "subscription" }}
{{ template "header" .}}
<section>
<h2>Unsubscribe</h2>
<p>Do you wish to unsubscribe from this mailing list?</p>
<form method="post">
<p>
<input type="hidden" name="unsubscribe" value="true" />
</p>
{{ if .Data.AllowBlacklist }}
<p>
<input id="privacy-blacklist" type="checkbox" name="blacklist" value="true" /> <label for="privacy-blacklist">Also unsubscribe from all future e-mails.</label>
</p>
{{ end }}
<p>
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
</p>
</form>
</section>
{{ if or .Data.AllowExport .Data.AllowWipe }}
<form id="data-form" method="post" action="" onsubmit="return handleData()">
<section>
<h2>Privacy and data</h2>
{{ if .Data.AllowExport }}
<div class="row">
<div class="one columns">
<input id="privacy-export" type="radio" name="data-action" value="export" required />
</div>
<div class="ten columns">
<label for="privacy-export"><strong>Export your data</strong></label>
<br />
A copy of your data will be e-mailed to you.
</div>
</div>
{{ end }}
{{ if .Data.AllowWipe }}
<div class="row">
<div class="one columns">
<input id="privacy-wipe" type="radio" name="data-action" value="wipe" required />
</div>
<div class="ten columns">
<label for="privacy-wipe"><strong>Wipe your data</strong></label>
<br />
Delete all your subscriptions and related data from our database permanently.
</div>
</div>
{{ end }}
<p>
<input type="submit" value="Continue" class="button button-outline" />
</p>
</section>
</form>
<script>
function handleData() {
var a = document.querySelector('input[name="data-action"]:checked').value,
f = document.querySelector("#data-form");
if (a == "export") {
f.action = "/subscription/export/{{ .Data.SubUUID }}";
return true;
} else if (confirm("Are you sure you want to delete all your subscription data permanently?")) {
f.action = "/subscription/wipe/{{ .Data.SubUUID }}";
return true;
}
return false;
}
</script>
{{ end }}
{{ template "footer" .}}
{{ end }}

View File

@ -1,35 +0,0 @@
{{ define "unsubscribe" }}
{{ template "header" .}}
{{ if not .Data.Unsubscribe }}
<h2>Unsubscribe</h2>
<p>Do you wish to unsubscribe from this mailing list?</p>
<form method="post">
<div>
<input type="hidden" name="unsubscribe" value="true" />
<button type="submit" class="button" id="btn-unsub">Unsubscribe</button>
</div>
</form>
{{ else }}
<h2>You have been unsubscribed</h2>
{{ if not .Data.Blacklist }}
<div class="unsub-all">
<p>
Unsubscribe from all future communications?
</p>
<form method="post">
<div>
<input type="hidden" name="unsubscribe" value="true" />
<input type="hidden" name="blacklist" value="true" />
<button type="submit" class="button button-inline" id="btn-unsuball">Unsubscribe all</button>
</div>
</form>
</div>
{{ else }}
<p>You've been unsubscribed from all future communications.</p>
{{ end }}
{{ end }}
{{ template "footer" .}}
{{ end }}

View File

@ -19,6 +19,7 @@ type Queries struct {
GetSubscriber *sqlx.Stmt `query:"get-subscriber"` GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
SubscriberExists *sqlx.Stmt `query:"subscriber-exists"`
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"` UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"` BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"`
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"` AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
@ -26,6 +27,7 @@ type Queries struct {
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"` UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"` DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
Unsubscribe *sqlx.Stmt `query:"unsubscribe"` Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
ExportSubscriberData *sqlx.Stmt `query:"export-subscriber-data"`
// Non-prepared arbitrary subscriber queries. // Non-prepared arbitrary subscriber queries.
QuerySubscribers string `query:"query-subscribers"` QuerySubscribers string `query:"query-subscribers"`

View File

@ -3,7 +3,10 @@
-- Get a single subscriber by id or UUID. -- Get a single subscriber by id or UUID.
SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; SELECT * FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
-- subscribers -- name: subscriber-exists
-- Check if a subscriber exists by id or UUID.
SELECT exists (SELECT true FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END);
-- name: get-subscribers-by-emails -- name: get-subscribers-by-emails
-- Get subscribers by emails. -- Get subscribers by emails.
SELECT * FROM subscribers WHERE email=ANY($1); SELECT * FROM subscribers WHERE email=ANY($1);
@ -107,8 +110,8 @@ INSERT INTO subscriber_lists (subscriber_id, list_id, status)
SET status = (CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END); SET status = (CASE WHEN $4='blacklisted' THEN 'unsubscribed'::subscription_status ELSE 'unconfirmed' END);
-- name: delete-subscribers -- name: delete-subscribers
-- Delete one or more subscribers. -- Delete one or more subscribers by ID or UUID.
DELETE FROM subscribers WHERE id = ANY($1); DELETE FROM subscribers WHERE CASE WHEN ARRAY_LENGTH($1::INT[], 1) > 0 THEN id = ANY($1) ELSE uuid = ANY($2::UUID[]) END;
-- name: blacklist-subscribers -- name: blacklist-subscribers
WITH b AS ( WITH b AS (
@ -149,6 +152,46 @@ UPDATE subscriber_lists SET status = 'unsubscribed' WHERE
-- If $3 is false, unsubscribe from the campaign's lists, otherwise all lists. -- If $3 is false, unsubscribe from the campaign's lists, otherwise all lists.
CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END; CASE WHEN $3 IS FALSE THEN list_id = ANY(SELECT list_id FROM lists) ELSE list_id != 0 END;
-- privacy
-- name: export-subscriber-data
WITH prof AS (
SELECT uuid, email, name, attribs, status, created_at, updated_at FROM subscribers WHERE
CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
),
subs AS (
SELECT JSON_AGG(
ROW_TO_JSON(
(SELECT l FROM (
SELECT subscriber_lists.status AS subscription_status,
(CASE WHEN lists.type = 'private' THEN 'Private list' ELSE lists.name END) as name,
lists.type, subscriber_lists.created_at
) l)
)
) AS lists FROM lists
LEFT JOIN subscriber_lists ON (subscriber_lists.list_id = lists.id)
WHERE subscriber_lists.subscriber_id = (SELECT id FROM prof)
GROUP BY subscriber_id
),
views AS (
SELECT JSON_AGG(t) AS views FROM
(SELECT subject as campaign, COUNT(subscriber_id) as views FROM campaign_views
LEFT JOIN campaigns ON (campaigns.id = campaign_views.campaign_id)
WHERE subscriber_id = (SELECT id FROM prof)
GROUP BY campaigns.id ORDER BY id) t
),
clicks AS (
SELECT JSON_AGG(t) AS views FROM
(SELECT url, COUNT(subscriber_id) as clicks FROM link_clicks
LEFT JOIN links ON (links.id = link_clicks.link_id)
WHERE subscriber_id = (SELECT id FROM prof)
GROUP BY links.id ORDER BY id) t
)
SELECT (SELECT email FROM prof) as email,
COALESCE((SELECT JSON_AGG(t) AS profile FROM prof t), '{}') AS profile,
COALESCE((SELECT * FROM subs), '[]') AS subscriptions,
COALESCE((SELECT * FROM views), '[]') AS campaign_views,
COALESCE((SELECT * FROM clicks), '[]') AS link_clicks;
-- Partial and RAW queries used to construct arbitrary subscriber -- Partial and RAW queries used to construct arbitrary subscriber
-- queries for segmentation follow. -- queries for segmentation follow.

View File

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -35,6 +36,16 @@ type subsWrap struct {
Page int `json:"page"` Page int `json:"page"`
} }
// subProfileData represents a subscriber's collated data in JSON
// for export.
type subProfileData struct {
Email string `db:"email" json:"-"`
Profile json.RawMessage `db:"profile" json:"profile,omitempty"`
Subscriptions json.RawMessage `db:"subscriptions" json:"subscriptions,omitempty"`
CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views,omitempty"`
LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
}
var dummySubscriber = models.Subscriber{ var dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app", Email: "dummy@listmonk.app",
Name: "Dummy Subscriber", Name: "Dummy Subscriber",
@ -327,7 +338,7 @@ func handleDeleteSubscribers(c echo.Context) error {
IDs = i IDs = i
} }
if _, err := app.Queries.DeleteSubscribers.Exec(IDs); err != nil { if _, err := app.Queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error deleting: %v", err)) fmt.Sprintf("Error deleting: %v", err))
} }
@ -418,6 +429,77 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true}) return c.JSON(http.StatusOK, okResp{true})
} }
// handleExportSubscriberData pulls the subscriber's profile,
// list subscriptions, campaign views and clicks and produces
// a JSON report. This is a privacy feature and depends on the
// configuration in app.Constants.Privacy.
func handleExportSubscriberData(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
)
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
_, b, err := exportSubscriberData(id, "", app.Constants.Privacy.Exportable, app)
if err != nil {
app.Logger.Printf("error exporting subscriber data: %s", err)
return echo.NewHTTPError(http.StatusBadRequest,
"Error exporting subscriber data.")
}
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Content-Disposition", `attachment; filename="profile.json"`)
return c.Blob(http.StatusOK, "application/json", b)
}
// exportSubscriberData collates the data of a subscriber including profile,
// subscriptions, campaign_views, link_clicks (if they're enabled in the config)
// and returns a formatted, indented JSON payload. Either takes a numeric id
// and an empty subUUID or takes 0 and a string subUUID.
func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) {
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
var (
data subProfileData
uu interface{}
)
// UUID should be a valid value or a nil.
if subUUID != "" {
uu = subUUID
}
if err := app.Queries.ExportSubscriberData.Get(&data, id, uu); err != nil {
return data, nil, err
}
// Filter out the non-exportable items.
if _, ok := exportables["profile"]; !ok {
data.Profile = nil
}
if _, ok := exportables["subscriptions"]; !ok {
data.Subscriptions = nil
}
if _, ok := exportables["campaign_views"]; !ok {
data.CampaignViews = nil
}
if _, ok := exportables["link_clicks"]; !ok {
data.LinkClicks = nil
}
// Marshal the data into an indented payload.
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return data, nil, err
}
return data, b, nil
}
// sanitizeSQLExp does basic sanitisation on arbitrary // sanitizeSQLExp does basic sanitisation on arbitrary
// SQL query expressions coming from the frontend. // SQL query expressions coming from the frontend.
func sanitizeSQLExp(q string) string { func sanitizeSQLExp(q string) string {

View File

@ -246,16 +246,17 @@ func normalizeTags(tags []string) []string {
return out return out
} }
// makeErrorTpl takes error details and returns an errorTpl // makeMsgTpl takes a page title, heading, and message and returns
// with the error details applied to be rendered in an HTML view. // a msgTpl that can be rendered as a HTML view. This is used for
func makeErrorTpl(pageTitle, heading, desc string) errorTpl { // rendering aribtrary HTML views with error and success messages.
func makeMsgTpl(pageTitle, heading, msg string) msgTpl {
if heading == "" { if heading == "" {
heading = pageTitle heading = pageTitle
} }
err := errorTpl{} err := msgTpl{}
err.Title = pageTitle err.Title = pageTitle
err.ErrorTitle = heading err.MessageTitle = heading
err.ErrorMessage = desc err.Message = msg
return err return err
} }