Browse Source

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.
Kailash Nadh 5 năm trước cách đây
mục cha
commit
ec097909db

+ 7 - 4
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
 	}
 

+ 10 - 3
frontend/src/views/Settings.vue

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

+ 26 - 5
internal/manager/manager.go

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

+ 18 - 12
internal/messenger/emailer.go

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

+ 11 - 1
internal/messenger/messenger.go

@@ -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 {

+ 9 - 8
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", "",

+ 2 - 1
schema.sql

@@ -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'),

+ 1 - 0
settings.go

@@ -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"`

+ 0 - 2
static/public/templates/subscription.html

@@ -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>

+ 1 - 1
subscribers.go

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