Merge branch 'privacy'
This commit is contained in:
commit
b63b31ec52
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
@ -53,8 +76,6 @@ password = "listmonk"
|
|||
database = "listmonk"
|
||||
ssl_mode = "disable"
|
||||
|
||||
# TQekh4quVgGc3HQ
|
||||
|
||||
|
||||
# SMTP servers.
|
||||
[smtp]
|
||||
|
|
|
@ -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>
|
||||
|
@ -56,16 +56,16 @@
|
|||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #F0F1F3;">
|
||||
<div class="gutter"> </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;"> </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"> {{ TrackView }}</div>
|
||||
<div class="gutter" style="padding: 30px;"> {{ TrackView }}</div>
|
||||
</body>
|
||||
</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 }}
|
|
@ -7,6 +7,8 @@ import {
|
|||
Select,
|
||||
Button,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
notification
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
|
3
go.mod
3
go.mod
|
@ -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
|
||||
|
|
8
go.sum
8
go.sum
|
@ -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=
|
||||
|
@ -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=
|
||||
|
|
87
handlers.go
87
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"
|
||||
)
|
||||
|
@ -38,6 +35,8 @@ 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)
|
||||
|
@ -45,6 +44,7 @@ func registerHandlers(e *echo.Echo) {
|
|||
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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
var (
|
||||
s = vals[i]
|
||||
val interface{}
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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.`))
|
||||
}
|
||||
}
|
||||
|
||||
attribs[key] = val
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(attribs) > 0 {
|
||||
j, _ := json.Marshal(attribs)
|
||||
return j, true
|
||||
// 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 (
|
||||
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.
|
||||
|
|
27
main.go
27
main.go
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -66,7 +66,7 @@ func (e *emailer) Name() string {
|
|||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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]
|
||||
}
|
||||
|
||||
// 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]
|
||||
err := srv.mailer.Send(&email.Email{
|
||||
From: fromAddr,
|
||||
To: toAddr,
|
||||
Subject: subject,
|
||||
HTML: m,
|
||||
From: fromAddr,
|
||||
To: toAddr,
|
||||
Subject: subject,
|
||||
HTML: m,
|
||||
Attachments: files,
|
||||
}, srv.SendTimeout)
|
||||
|
||||
return err
|
||||
|
|
|
@ -1,10 +1,34 @@
|
|||
package messenger
|
||||
|
||||
import "net/textproto"
|
||||
|
||||
// Messenger is an interface for a generic messaging backend,
|
||||
// for instance, e-mail, SMS etc.
|
||||
type Messenger interface {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
|
|||
err = app.Messenger.Push(app.Constants.FromEmail,
|
||||
app.Constants.NotifyEmails,
|
||||
subject,
|
||||
b.Bytes())
|
||||
b.Bytes(),
|
||||
nil)
|
||||
if err != nil {
|
||||
app.Logger.Printf("error sending admin notification (%s): %v", subject, err)
|
||||
return err
|
||||
|
@ -30,3 +31,18 @@ func sendNotification(tpl, subject string, data map[string]interface{}, app *App
|
|||
|
||||
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
171
public.go
|
@ -7,10 +7,11 @@ import (
|
|||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/knadh/listmonk/messenger"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// tplRenderer wraps a template.tplRenderer for echo.
|
||||
|
@ -37,19 +38,20 @@ type publicTpl struct {
|
|||
|
||||
type unsubTpl struct {
|
||||
publicTpl
|
||||
Unsubscribe bool
|
||||
Blacklist bool
|
||||
SubUUID string
|
||||
AllowBlacklist bool
|
||||
AllowExport bool
|
||||
AllowWipe bool
|
||||
}
|
||||
|
||||
type errorTpl struct {
|
||||
type msgTpl struct {
|
||||
publicTpl
|
||||
ErrorTitle string
|
||||
ErrorMessage string
|
||||
MessageTitle string
|
||||
Message 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}$")
|
||||
pixelPNG = drawTransparentImage(3, 14)
|
||||
pixelPNG = drawTransparentImage(3, 14)
|
||||
)
|
||||
|
||||
// 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.
|
||||
func handleUnsubscribePage(c echo.Context) error {
|
||||
// handleSubscriptionPage renders the subscription management page and
|
||||
// handles unsubscriptions.
|
||||
func handleSubscriptionPage(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
campUUID = c.Param("campUUID")
|
||||
subUUID = c.Param("subUUID")
|
||||
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
|
||||
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
|
||||
|
||||
out = unsubTpl{}
|
||||
out = unsubTpl{}
|
||||
)
|
||||
out.Unsubscribe = unsub
|
||||
out.Blacklist = blacklist
|
||||
out.SubUUID = subUUID
|
||||
out.Title = "Unsubscribe from mailing list"
|
||||
|
||||
if !regexValidUUID.MatchString(campUUID) ||
|
||||
!regexValidUUID.MatchString(subUUID) {
|
||||
return c.Render(http.StatusBadRequest, "error",
|
||||
makeErrorTpl("Invalid request", "",
|
||||
`The unsubscription request contains invalid IDs.
|
||||
Please follow the correct link.`))
|
||||
}
|
||||
out.AllowBlacklist = app.Constants.Privacy.AllowBlacklist
|
||||
out.AllowExport = app.Constants.Privacy.AllowExport
|
||||
out.AllowWipe = app.Constants.Privacy.AllowWipe
|
||||
|
||||
// Unsubscribe.
|
||||
if unsub {
|
||||
res, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist)
|
||||
if err != nil {
|
||||
app.Logger.Printf("Error unsubscribing : %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
"There was an internal error while unsubscribing you.")
|
||||
// Is blacklisting allowed?
|
||||
if !app.Constants.Privacy.AllowBlacklist {
|
||||
blacklist = false
|
||||
}
|
||||
|
||||
if !blacklist {
|
||||
num, _ := res.RowsAffected()
|
||||
if num == 0 {
|
||||
return c.Render(http.StatusBadRequest, "error",
|
||||
makeErrorTpl("Already unsubscribed", "",
|
||||
`You are not subscribed to this mailing list.
|
||||
You may have already unsubscribed.`))
|
||||
}
|
||||
if _, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist); err != nil {
|
||||
app.Logger.Printf("error unsubscribing: %v", err)
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error", "",
|
||||
`Error processing request. Please retry.`))
|
||||
}
|
||||
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.
|
||||
|
@ -116,18 +110,12 @@ func handleLinkRedirect(c echo.Context) error {
|
|||
campUUID = c.Param("campUUID")
|
||||
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
|
||||
if err := app.Queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
|
||||
app.Logger.Printf("error fetching redirect link: %s", err)
|
||||
return c.Render(http.StatusInternalServerError, "error",
|
||||
makeErrorTpl("Error opening link", "",
|
||||
return c.Render(http.StatusInternalServerError, "message",
|
||||
makeMsgTpl("Error opening link", "",
|
||||
"There was an error opening the link. Please try later."))
|
||||
}
|
||||
|
||||
|
@ -143,17 +131,100 @@ func handleRegisterCampaignView(c echo.Context) error {
|
|||
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)
|
||||
}
|
||||
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")
|
||||
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
|
||||
// and returns the PNG bytes.
|
||||
func drawTransparentImage(h, w int) []byte {
|
||||
|
|
|
@ -1,78 +1,273 @@
|
|||
/* 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 {
|
||||
background: #f9f9f9;
|
||||
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: #111;
|
||||
background: #f9f9f9;
|
||||
font-family: "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
color: #7f2aff;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-weight: 400;
|
||||
a:hover {
|
||||
color: #111;
|
||||
}
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 45px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #7f2aff;
|
||||
padding: 10px 30px;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #ffff;
|
||||
display: inline-block;
|
||||
background: #7f2aff;
|
||||
padding: 10px 30px;
|
||||
border-radius: 3px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #ffff;
|
||||
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 {
|
||||
background: #fff;
|
||||
margin-top: 60px;
|
||||
max-width: 600px;
|
||||
padding: 45px;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
border: 1px solid #eee;
|
||||
background: #fff;
|
||||
margin-top: 60px;
|
||||
max-width: 600px;
|
||||
padding: 45px;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 30px;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 30px;
|
||||
padding-top: 30px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 0.775em;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
font-size: 0.775em;
|
||||
margin-top: 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;
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
{{ define "error" }}
|
||||
{{ define "message" }}
|
||||
{{ template "header" .}}
|
||||
|
||||
<h2>{{ .Data.ErrorTitle }}</h2>
|
||||
<h2>{{ .Data.Title }}</h2>
|
||||
<div>
|
||||
{{ .Data.ErrorMessage }}
|
||||
{{ .Data.Message }}
|
||||
</div>
|
||||
|
||||
{{ template "footer" .}}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -19,6 +19,7 @@ type Queries struct {
|
|||
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
|
||||
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
|
||||
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
|
||||
SubscriberExists *sqlx.Stmt `query:"subscriber-exists"`
|
||||
UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"`
|
||||
BlacklistSubscribers *sqlx.Stmt `query:"blacklist-subscribers"`
|
||||
AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"`
|
||||
|
@ -26,6 +27,7 @@ type Queries struct {
|
|||
UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"`
|
||||
DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"`
|
||||
Unsubscribe *sqlx.Stmt `query:"unsubscribe"`
|
||||
ExportSubscriberData *sqlx.Stmt `query:"export-subscriber-data"`
|
||||
|
||||
// Non-prepared arbitrary subscriber queries.
|
||||
QuerySubscribers string `query:"query-subscribers"`
|
||||
|
|
49
queries.sql
49
queries.sql
|
@ -3,7 +3,10 @@
|
|||
-- Get a single subscriber by id or UUID.
|
||||
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
|
||||
-- Get subscribers by emails.
|
||||
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);
|
||||
|
||||
-- name: delete-subscribers
|
||||
-- Delete one or more subscribers.
|
||||
DELETE FROM subscribers WHERE id = ANY($1);
|
||||
-- Delete one or more subscribers by ID or UUID.
|
||||
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
|
||||
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.
|
||||
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
|
||||
-- queries for segmentation follow.
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
@ -35,6 +36,16 @@ type subsWrap struct {
|
|||
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{
|
||||
Email: "dummy@listmonk.app",
|
||||
Name: "Dummy Subscriber",
|
||||
|
@ -327,7 +338,7 @@ func handleDeleteSubscribers(c echo.Context) error {
|
|||
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,
|
||||
fmt.Sprintf("Error deleting: %v", err))
|
||||
}
|
||||
|
@ -418,6 +429,77 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
|
|||
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
|
||||
// SQL query expressions coming from the frontend.
|
||||
func sanitizeSQLExp(q string) string {
|
||||
|
|
13
utils.go
13
utils.go
|
@ -246,16 +246,17 @@ func normalizeTags(tags []string) []string {
|
|||
return out
|
||||
}
|
||||
|
||||
// makeErrorTpl takes error details and returns an errorTpl
|
||||
// with the error details applied to be rendered in an HTML view.
|
||||
func makeErrorTpl(pageTitle, heading, desc string) errorTpl {
|
||||
// makeMsgTpl takes a page title, heading, and message and returns
|
||||
// a msgTpl that can be rendered as a HTML view. This is used for
|
||||
// rendering aribtrary HTML views with error and success messages.
|
||||
func makeMsgTpl(pageTitle, heading, msg string) msgTpl {
|
||||
if heading == "" {
|
||||
heading = pageTitle
|
||||
}
|
||||
err := errorTpl{}
|
||||
err := msgTpl{}
|
||||
err.Title = pageTitle
|
||||
err.ErrorTitle = heading
|
||||
err.ErrorMessage = desc
|
||||
err.MessageTitle = heading
|
||||
err.Message = msg
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue