Add support for `List-Unsubscribe` header.

- Added as a setting in the settings UI.
- Refactor Messenger.Push() method to accept messenger.Message{}
  instead of a growing number of positional arguments.
This commit is contained in:
Kailash Nadh 2020-08-01 17:54:51 +05:30
parent 7ead052054
commit ec097909db
11 changed files with 86 additions and 37 deletions

View File

@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
"github.com/labstack/echo" "github.com/labstack/echo"
@ -558,10 +559,12 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err
fmt.Sprintf("Error rendering message: %v", err)) fmt.Sprintf("Error rendering message: %v", err))
} }
if err := app.messenger.Push(camp.FromEmail, if err := app.messenger.Push(messenger.Message{
[]string{sub.Email}, From: camp.FromEmail,
m.Subject(), To: []string{sub.Email},
m.Body(), nil); err != nil { Subject: m.Subject(),
Body: m.Body(),
}); err != nil {
return err return err
} }

View File

@ -104,6 +104,13 @@
<b-tab-item label="Privacy"> <b-tab-item label="Privacy">
<div class="items"> <div class="items">
<b-field label="Include `List-Unsubscribe` header"
message="Include unsubscription headers that allow e-mail clients to
allow users to unsubscribe in a single click.">
<b-switch v-model="form['privacy.unsubscribe_header']"
name="privacy.unsubscribe_header" />
</b-field>
<b-field label="Allow blocklisting" <b-field label="Allow blocklisting"
message="Allow subscribers to unsubscribe from all mailing lists and mark message="Allow subscribers to unsubscribe from all mailing lists and mark
themselves as blocklisted?"> themselves as blocklisted?">
@ -118,9 +125,9 @@
</b-field> </b-field>
<b-field label="Allow wiping" <b-field label="Allow wiping"
message="Allow subscribers to delete themselves from the database? message="Allow subscribers to delete themselves including their
This deletes the subscriber and all their subscriptions. subscriptions and all other data from the database.
Their association to campaign views and link clicks are also Campaign views and link clicks are also
removed while views and click counts remain (with no subscriber removed while views and click counts remain (with no subscriber
associated to them) so that stats and analytics aren't affected."> associated to them) so that stats and analytics aren't affected.">
<b-switch v-model="form['privacy.allow_wipe']" <b-switch v-model="form['privacy.allow_wipe']"

View File

@ -269,6 +269,7 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
LinkTrackURL: cs.LinkTrackURL, LinkTrackURL: cs.LinkTrackURL,
ViewTrackURL: cs.ViewTrackURL, ViewTrackURL: cs.ViewTrackURL,
MessageURL: cs.MessageURL, MessageURL: cs.MessageURL,
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
}, newManagerDB(q), campNotifCB, lo) }, newManagerDB(q), campNotifCB, lo)
} }

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"net/textproto"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -95,6 +96,7 @@ type Config struct {
OptinURL string OptinURL string
MessageURL string MessageURL string
ViewTrackURL string ViewTrackURL string
UnsubHeader bool
} }
type msgError struct { type msgError struct {
@ -249,9 +251,23 @@ func (m *Manager) messageWorker() {
} }
numMsg++ numMsg++
err := m.messengers[msg.Campaign.MessengerID].Push( // Outgoing message.
msg.from, []string{msg.to}, msg.subject, msg.body, nil) out := messenger.Message{
if err != nil { From: msg.from,
To: []string{msg.to},
Subject: msg.subject,
Body: msg.body,
}
// Attach List-Unsubscribe headers?
if m.cfg.UnsubHeader {
h := textproto.MIMEHeader{}
h.Set("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`)
out.Headers = h
}
if err := m.messengers[msg.Campaign.MessengerID].Push(out); err != nil {
m.logger.Printf("error sending message in campaign %s: %v", msg.Campaign.Name, err) m.logger.Printf("error sending message in campaign %s: %v", msg.Campaign.Name, err)
select { select {
@ -265,8 +281,13 @@ func (m *Manager) messageWorker() {
if !ok { if !ok {
return return
} }
err := m.messengers[msg.Messenger].Push(
msg.From, msg.To, msg.Subject, msg.Body, nil) err := m.messengers[msg.Messenger].Push(messenger.Message{
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Body: msg.Body,
})
if err != nil { if err != nil {
m.logger.Printf("error sending message '%s': %v", msg.Subject, err) m.logger.Printf("error sending message '%s': %v", msg.Subject, err)
} }

View File

@ -86,7 +86,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, atts []Attachment) error { func (e *Emailer) Push(m Message) error {
// If there are more than one SMTP servers, send to a random // If there are more than one SMTP servers, send to a random
// one from the list. // one from the list.
var ( var (
@ -101,9 +101,9 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
// Are there attachments? // Are there attachments?
var files []smtppool.Attachment var files []smtppool.Attachment
if atts != nil { if m.Attachments != nil {
files = make([]smtppool.Attachment, 0, len(atts)) files = make([]smtppool.Attachment, 0, len(m.Attachments))
for _, f := range atts { for _, f := range m.Attachments {
a := smtppool.Attachment{ a := smtppool.Attachment{
Filename: f.Name, Filename: f.Name,
Header: f.Header, Header: f.Header,
@ -114,21 +114,27 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
} }
} }
mtext, err := html2text.FromString(string(m), html2text.Options{PrettyTables: true}) mtext, err := html2text.FromString(string(m.Body),
html2text.Options{PrettyTables: true})
if err != nil { if err != nil {
return err return err
} }
em := smtppool.Email{ em := smtppool.Email{
From: fromAddr, From: m.From,
To: toAddr, To: m.To,
Subject: subject, Subject: m.Subject,
Attachments: files, Attachments: files,
} }
// If there are custom e-mail headers, attach them. em.Headers = textproto.MIMEHeader{}
// Attach e-mail level headers.
if len(m.Headers) > 0 {
em.Headers = m.Headers
}
// Attach SMTP level headers.
if len(srv.EmailHeaders) > 0 { if len(srv.EmailHeaders) > 0 {
em.Headers = textproto.MIMEHeader{}
for k, v := range srv.EmailHeaders { for k, v := range srv.EmailHeaders {
em.Headers.Set(k, v) em.Headers.Set(k, v)
} }
@ -136,11 +142,11 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
switch srv.EmailFormat { switch srv.EmailFormat {
case "html": case "html":
em.HTML = m em.HTML = m.Body
case "plain": case "plain":
em.Text = []byte(mtext) em.Text = []byte(mtext)
default: default:
em.HTML = m em.HTML = m.Body
em.Text = []byte(mtext) em.Text = []byte(mtext)
} }

View File

@ -6,11 +6,21 @@ import "net/textproto"
// 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, atts []Attachment) error Push(Message) error
Flush() error Flush() error
Close() error Close() error
} }
// Message is the message pushed to a Messenger.
type Message struct {
From string
To []string
Subject string
Body []byte
Headers textproto.MIMEHeader
Attachments []Attachment
}
// Attachment represents a file or blob attachment that can be // Attachment represents a file or blob attachment that can be
// sent along with a message by a Messenger. // sent along with a message by a Messenger.
type Attachment struct { type Attachment struct {

View File

@ -146,7 +146,7 @@ func handleSubscriptionPage(c echo.Context) error {
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 = c.Request().Method == http.MethodPost
blocklist, _ = strconv.ParseBool(c.FormValue("blocklist")) blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
out = unsubTpl{} out = unsubTpl{}
) )
@ -366,19 +366,20 @@ func handleSelfExportSubscriberData(c echo.Context) error {
} }
// Send the data as a JSON attachment to the subscriber. // Send the data as a JSON attachment to the subscriber.
const fname = "profile.json" const fname = "data.json"
if err := app.messenger.Push(app.constants.FromEmail, if err := app.messenger.Push(messenger.Message{
[]string{data.Email}, From: app.constants.FromEmail,
"Your profile data", To: []string{data.Email},
msg.Bytes(), Subject: "Your data",
[]messenger.Attachment{ Body: msg.Bytes(),
Attachments: []messenger.Attachment{
{ {
Name: fname, Name: fname,
Content: b, Content: b,
Header: messenger.MakeAttachmentHeader(fname, "base64"), Header: messenger.MakeAttachmentHeader(fname, "base64"),
}, },
}, },
); err != nil { }); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err) app.log.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage, return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl("Error e-mailing data", "", makeMsgTpl("Error e-mailing data", "",

View File

@ -165,7 +165,7 @@ CREATE TABLE settings (
); );
DROP INDEX IF EXISTS idx_settings_key; CREATE INDEX idx_settings_key ON settings(key); DROP INDEX IF EXISTS idx_settings_key; CREATE INDEX idx_settings_key ON settings(key);
INSERT INTO settings (key, value) VALUES INSERT INTO settings (key, value) VALUES
('app.root_url', '"https://localhost:9000"'), ('app.root_url', '"http://localhost:9000"'),
('app.favicon_url', '""'), ('app.favicon_url', '""'),
('app.from_email', '"listmonk <noreply@listmonk.yoursite.com>"'), ('app.from_email', '"listmonk <noreply@listmonk.yoursite.com>"'),
('app.logo_url', '"http://localhost:9000/public/static/logo.png"'), ('app.logo_url', '"http://localhost:9000/public/static/logo.png"'),
@ -174,6 +174,7 @@ INSERT INTO settings (key, value) VALUES
('app.batch_size', '1000'), ('app.batch_size', '1000'),
('app.max_send_errors', '1000'), ('app.max_send_errors', '1000'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'), ('privacy.allow_blocklist', 'true'),
('privacy.allow_export', 'true'), ('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'), ('privacy.allow_wipe', 'true'),

View File

@ -24,6 +24,7 @@ type settings struct {
Messengers []interface{} `json:"messengers"` Messengers []interface{} `json:"messengers"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
PrivacyAllowExport bool `json:"privacy.allow_export"` PrivacyAllowExport bool `json:"privacy.allow_export"`
PrivacyAllowWipe bool `json:"privacy.allow_wipe"` PrivacyAllowWipe bool `json:"privacy.allow_wipe"`

View File

@ -5,8 +5,6 @@
<p>Do you wish to unsubscribe from this mailing list?</p> <p>Do you wish to unsubscribe from this mailing list?</p>
<form method="post"> <form method="post">
<div> <div>
<input type="hidden" name="unsubscribe" value="true" />
{{ if .Data.AllowBlocklist }} {{ if .Data.AllowBlocklist }}
<p> <p>
<input id="privacy-blocklist" type="checkbox" name="blocklist" value="true" /> <label for="privacy-blocklist">Also unsubscribe from all future e-mails.</label> <input id="privacy-blocklist" type="checkbox" name="blocklist" value="true" /> <label for="privacy-blocklist">Also unsubscribe from all future e-mails.</label>

View File

@ -495,7 +495,7 @@ func handleExportSubscriberData(c echo.Context) error {
} }
c.Response().Header().Set("Cache-Control", "no-cache") c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Content-Disposition", `attachment; filename="profile.json"`) c.Response().Header().Set("Content-Disposition", `attachment; filename="data.json"`)
return c.Blob(http.StatusOK, "application/json", b) return c.Blob(http.StatusOK, "application/json", b)
} }