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:
parent
7ead052054
commit
ec097909db
11 changed files with 86 additions and 37 deletions
11
campaigns.go
11
campaigns.go
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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']"
|
||||
|
|
1
init.go
1
init.go
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
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 {
|
||||
em.Headers = textproto.MIMEHeader{}
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
17
public.go
17
public.go
|
@ -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", "",
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue