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
campaigns.go
11
campaigns.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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']"
|
||||||
|
|
1
init.go
1
init.go
|
@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
17
public.go
17
public.go
|
@ -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", "",
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue