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"
"github.com/gofrs/uuid"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"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))
}
if err := app.messenger.Push(camp.FromEmail,
[]string{sub.Email},
m.Subject(),
m.Body(), nil); err != nil {
if err := app.messenger.Push(messenger.Message{
From: camp.FromEmail,
To: []string{sub.Email},
Subject: m.Subject(),
Body: m.Body(),
}); err != nil {
return err
}

View File

@ -104,6 +104,13 @@
<b-tab-item label="Privacy">
<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"
message="Allow subscribers to unsubscribe from all mailing lists and mark
themselves as blocklisted?">
@ -118,9 +125,9 @@
</b-field>
<b-field label="Allow wiping"
message="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
message="Allow subscribers to delete themselves including their
subscriptions and all other data from the database.
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.">
<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,
ViewTrackURL: cs.ViewTrackURL,
MessageURL: cs.MessageURL,
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
}, newManagerDB(q), campNotifCB, lo)
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"html/template"
"log"
"net/textproto"
"strings"
"sync"
"time"
@ -95,6 +96,7 @@ type Config struct {
OptinURL string
MessageURL string
ViewTrackURL string
UnsubHeader bool
}
type msgError struct {
@ -249,9 +251,23 @@ func (m *Manager) messageWorker() {
}
numMsg++
err := m.messengers[msg.Campaign.MessengerID].Push(
msg.from, []string{msg.to}, msg.subject, msg.body, nil)
if err != nil {
// Outgoing message.
out := messenger.Message{
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)
select {
@ -265,8 +281,13 @@ func (m *Manager) messageWorker() {
if !ok {
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 {
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.
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
// one from the list.
var (
@ -101,9 +101,9 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
// Are there attachments?
var files []smtppool.Attachment
if atts != nil {
files = make([]smtppool.Attachment, 0, len(atts))
for _, f := range atts {
if m.Attachments != nil {
files = make([]smtppool.Attachment, 0, len(m.Attachments))
for _, f := range m.Attachments {
a := smtppool.Attachment{
Filename: f.Name,
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 {
return err
}
em := smtppool.Email{
From: fromAddr,
To: toAddr,
Subject: subject,
From: m.From,
To: m.To,
Subject: m.Subject,
Attachments: files,
}
// If there are custom e-mail headers, attach them.
if len(srv.EmailHeaders) > 0 {
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 {
for k, v := range srv.EmailHeaders {
em.Headers.Set(k, v)
}
@ -136,11 +142,11 @@ func (e *Emailer) Push(fromAddr string, toAddr []string, subject string, m []byt
switch srv.EmailFormat {
case "html":
em.HTML = m
em.HTML = m.Body
case "plain":
em.Text = []byte(mtext)
default:
em.HTML = m
em.HTML = m.Body
em.Text = []byte(mtext)
}

View File

@ -6,11 +6,21 @@ import "net/textproto"
// for instance, e-mail, SMS etc.
type Messenger interface {
Name() string
Push(fromAddr string, toAddr []string, subject string, message []byte, atts []Attachment) error
Push(Message) error
Flush() 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
// sent along with a message by a Messenger.
type Attachment struct {

View File

@ -146,7 +146,7 @@ func handleSubscriptionPage(c echo.Context) error {
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
unsub = c.Request().Method == http.MethodPost
blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
out = unsubTpl{}
)
@ -366,19 +366,20 @@ func handleSelfExportSubscriberData(c echo.Context) error {
}
// Send the data as a JSON attachment to the subscriber.
const fname = "profile.json"
if err := app.messenger.Push(app.constants.FromEmail,
[]string{data.Email},
"Your profile data",
msg.Bytes(),
[]messenger.Attachment{
const fname = "data.json"
if err := app.messenger.Push(messenger.Message{
From: app.constants.FromEmail,
To: []string{data.Email},
Subject: "Your data",
Body: msg.Bytes(),
Attachments: []messenger.Attachment{
{
Name: fname,
Content: b,
Header: messenger.MakeAttachmentHeader(fname, "base64"),
},
},
); err != nil {
}); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
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);
INSERT INTO settings (key, value) VALUES
('app.root_url', '"https://localhost:9000"'),
('app.root_url', '"http://localhost:9000"'),
('app.favicon_url', '""'),
('app.from_email', '"listmonk <noreply@listmonk.yoursite.com>"'),
('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.max_send_errors', '1000'),
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'),
('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'),

View File

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

View File

@ -5,8 +5,6 @@
<p>Do you wish to unsubscribe from this mailing list?</p>
<form method="post">
<div>
<input type="hidden" name="unsubscribe" value="true" />
{{ if .Data.AllowBlocklist }}
<p>
<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("Content-Disposition", `attachment; filename="profile.json"`)
c.Response().Header().Set("Content-Disposition", `attachment; filename="data.json"`)
return c.Blob(http.StatusOK, "application/json", b)
}