Skip to content

Commit

Permalink
Add support for List-Unsubscribe header.
Browse files Browse the repository at this point in the history
- Added as a setting in the settings UI.
- Refactor Messenger.Push() method to accept messenger.Message{}
  instead of a growing number of positional arguments.
  • Loading branch information
knadh committed Aug 1, 2020
1 parent 7ead052 commit ec09790
Show file tree
Hide file tree
Showing 11 changed files with 86 additions and 37 deletions.
11 changes: 7 additions & 4 deletions campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
13 changes: 10 additions & 3 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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?">
Expand All @@ -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']"
Expand Down
1 change: 1 addition & 0 deletions init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

}
Expand Down
31 changes: 26 additions & 5 deletions internal/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"html/template"
"log"
"net/textproto"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -95,6 +96,7 @@ type Config struct {
OptinURL string
MessageURL string
ViewTrackURL string
UnsubHeader bool
}

type msgError struct {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
30 changes: 18 additions & 12 deletions internal/messenger/emailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -114,33 +114,39 @@ 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)
}
}

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)
}

Expand Down
12 changes: 11 additions & 1 deletion internal/messenger/messenger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 9 additions & 8 deletions public.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
)
Expand Down Expand Up @@ -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", "",
Expand Down
3 changes: 2 additions & 1 deletion schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>"'),
('app.logo_url', '"http://localhost:9000/public/static/logo.png"'),
Expand All @@ -174,6 +174,7 @@ INSERT INTO settings (key, value) VALUES
('app.batch_size', '1000'),
('app.max_send_errors', '1000'),
('app.notify_emails', '["[email protected]", "[email protected]"]'),
('privacy.unsubscribe_header', 'true'),
('privacy.allow_blocklist', 'true'),
('privacy.allow_export', 'true'),
('privacy.allow_wipe', 'true'),
Expand Down
1 change: 1 addition & 0 deletions settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 0 additions & 2 deletions static/public/templates/subscription.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ <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" />

{{ 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>
Expand Down
2 changes: 1 addition & 1 deletion subscribers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down

0 comments on commit ec09790

Please sign in to comment.