Parcourir la 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 il y a 5 ans
Parent
commit
ec097909db

+ 7 - 4
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,
-		[]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
 		return err
 	}
 	}
 
 

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

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

+ 26 - 5
internal/manager/manager.go

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

+ 18 - 12
internal/messenger/emailer.go

@@ -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 {
-		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{
 			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,
-		To:          toAddr,
-		Subject:     subject,
+		From:        m.From,
+		To:          m.To,
+		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)
 	}
 	}
 
 

+ 11 - 1
internal/messenger/messenger.go

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

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

+ 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);
 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'),

+ 1 - 0
settings.go

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

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

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

+ 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("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)
 }
 }