~sircmpwn/aerc

Teach the reply command about mailing lists v1 PROPOSED

Ben Burwell: 2
 Teach the reply command about mailing lists
 Add docs for reply -T

 7 files changed, 162 insertions(+), 69 deletions(-)
> Would it make sense to fallback to List-ID and try to parse it as a
> mail address?  Probably not RFC approved, but may be useful for some
> random list that doesn't set the List-Post header?
I would rather not add features that deviate from RFCs until we need
them for a sort of "quirks mode."
> I don't think that we need to (nor should) set all possible
> combinations as keyboard shortcuts. [...] People should set up those
> more "special" ones themselves.
Fair enough -- I was sort of on the fence about including the shortcuts;
I've removed them from v2.
> I thought it contained helpers to cope with actual mailing lists, not
> helpers to parse mail headers.
Well, it is to cope with mailing lists, specifically, their special
headers ;)

Enjoy your vacation!
Hi all! Mail list developer in-training here.

On Mon Jan 6, 2020 at 10:28 AM, Ben Burwell wrote:
Next
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~sircmpwn/aerc/patches/9462/mbox | git am -3
Learn more about email & git

[PATCH 1/2] Teach the reply command about mailing lists Export this patch

Mailing lists can add a "List-Post" header containing a mailto URL
specifying where mail should be sent to post to the list. Add a -l
option to the reply command which will send the reply to the mailing
list found in the List-Post header, if present.
This header is specified in RFC 2369, the same RFC which specifies the
use of the List-Unsubscribe header with the same format. Thus, extract
and re-use the parsing logic from unsubscribe.go.

Additionally, perform cleanup of lingering references to when reply and
forward were implemented in the same file.
---
 commands/msg/reply.go                         | 145 +++++++++++++-----
 commands/msg/unsubscribe.go                   |  28 +---
 config/binds.conf                             |   4 +
 doc/aerc.1.scd                                |   7 +-
 lib/mailing_list.go                           |  34 ++++
 .../mailing_list_test.go                      |   9 +-
 6 files changed, 159 insertions(+), 68 deletions(-)
 create mode 100644 lib/mailing_list.go
 rename commands/msg/unsubscribe_test.go => lib/mailing_list_test.go (85%)

diff --git a/commands/msg/reply.go b/commands/msg/reply.go
index a7379d7..e898196 100644
--- a/commands/msg/reply.go
+++ b/commands/msg/reply.go
@@ -6,14 +6,22 @@ import (
	"fmt"
	"io"
	gomail "net/mail"
	"net/url"
	"strings"

	"git.sr.ht/~sircmpwn/getopt"

	"git.sr.ht/~sircmpwn/aerc/lib"
	"git.sr.ht/~sircmpwn/aerc/models"
	"git.sr.ht/~sircmpwn/aerc/widgets"
)

const (
	replyTypeSender int = iota
	replyTypeAll
	replyTypeList
)

type reply struct{}

func init() {
@@ -29,17 +37,18 @@ func (reply) Complete(aerc *widgets.Aerc, args []string) []string {
}

func (reply) Execute(aerc *widgets.Aerc, args []string) error {
	opts, optind, err := getopt.Getopts(args, "aqT:")
	opts, optind, err := getopt.Getopts(args, "aqlT:")
	if err != nil {
		return err
	}
	if optind != len(args) {
		return errors.New("Usage: reply [-aq -T <template>]")
		return errors.New("Usage: reply [-aql -T <template>]")
	}
	var (
		quote    bool
		replyAll bool
		template string
		quote     bool
		replyAll  bool
		template  string
		replyList bool
	)
	for _, opt := range opts {
		switch opt.Option {
@@ -49,9 +58,22 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
			quote = true
		case 'T':
			template = opt.Value
		case 'l':
			replyList = true
		}
	}

	replyType := replyTypeSender

	if replyAll && replyList {
		return errors.New("reply-all and reply-list are mutually exclusive")
	}
	if replyAll {
		replyType = replyTypeAll
	} else if replyList {
		replyType = replyTypeList
	}

	widget := aerc.SelectedTab().(widgets.ProvidesMessage)
	acct := widget.SelectedAccount()

@@ -70,37 +92,9 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
	}
	acct.Logger().Println("Replying to email " + msg.Envelope.MessageId)

	var (
		to     []string
		cc     []string
		toList []*models.Address
	)
	if args[0] == "reply" {
		if len(msg.Envelope.ReplyTo) != 0 {
			toList = msg.Envelope.ReplyTo
		} else {
			toList = msg.Envelope.From
		}
		for _, addr := range toList {
			if addr.Name != "" {
				to = append(to, fmt.Sprintf("%s <%s@%s>",
					addr.Name, addr.Mailbox, addr.Host))
			} else {
				to = append(to, fmt.Sprintf("<%s@%s>", addr.Mailbox, addr.Host))
			}
		}
		if replyAll {
			for _, addr := range msg.Envelope.Cc {
				cc = append(cc, addr.Format())
			}
			for _, addr := range msg.Envelope.To {
				address := fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
				if address == us.Address {
					continue
				}
				to = append(to, addr.Format())
			}
		}
	to, cc, err := getReplyAddresses(msg, replyType, us)
	if err != nil {
		return err
	}

	var subject string
@@ -130,9 +124,7 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
			return err
		}

		if args[0] == "reply" {
			composer.FocusTerminal()
		}
		composer.FocusTerminal()

		tab := aerc.NewTab(composer, subject)
		composer.OnHeaderChange("Subject", func(subject string) {
@@ -183,3 +175,78 @@ func findPlaintext(bs *models.BodyStructure,

	return nil, nil
}

// getReplyAddresses returns the list of To: and CC: addresses to use in the
// reply we are composing, depending on the reply type (all, sender, list).
func getReplyAddresses(msg *models.MessageInfo, replyType int,
	us *gomail.Address) ([]string, []string, error) {
	if replyType == replyTypeList {
		listPost, err := getListReply(msg)
		return []string{listPost}, nil, err
	}

	var (
		to []string
		cc []string
	)
	toList := getReplyTo(msg.Envelope)
	for _, addr := range toList {
		to = append(to, addr.Format())
	}

	if replyType == replyTypeAll {
		for _, addr := range msg.Envelope.Cc {
			cc = append(cc, addr.Format())
		}
		for _, addr := range msg.Envelope.To {
			address := fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
			if address == us.Address {
				continue
			}
			to = append(to, addr.Format())
		}
	}
	return to, cc, nil
}

// getReplyTo gets the addresses to reply to: the content of the Reply-To
// header if present, or the From header if not.
func getReplyTo(env *models.Envelope) []*models.Address {
	if len(env.ReplyTo) > 0 {
		return env.ReplyTo
	}
	return env.From
}

// getListReply searches the list-post header for a mailto: URL and returns the
// enclosed address, or an error.
func getListReply(msg *models.MessageInfo) (string, error) {
	listPost, err := msg.RFC822Headers.Text("list-post")
	if err != nil {
		return "", fmt.Errorf("get reply addresses: %w", err)
	}
	if listPost == "" {
		return "", errors.New("no list-post header found")
	}
	urls, err := lib.ParseURLList(listPost)
	if err != nil {
		return "", fmt.Errorf("could not parse list-post header: %w", err)
	}
	addr, err := firstMailtoAddress(urls)
	if err != nil {
		return "", fmt.Errorf("get reply addresses: %w", err)
	}
	return addr, nil
}

// firstMailtoAddress grabs the first mailto: URL in the list and returns the
// address, or an error if none is found.
func firstMailtoAddress(urls []*url.URL) (string, error) {
	for _, u := range urls {
		if u.Scheme != "mailto" {
			continue
		}
		return u.Opaque, nil
	}
	return "", errors.New("no mailto address found")
}
diff --git a/commands/msg/unsubscribe.go b/commands/msg/unsubscribe.go
index 5ffec46..d9fb259 100644
--- a/commands/msg/unsubscribe.go
+++ b/commands/msg/unsubscribe.go
@@ -1,7 +1,6 @@
package msg

import (
	"bufio"
	"errors"
	"net/url"
	"strings"
@@ -42,7 +41,10 @@ func (Unsubscribe) Execute(aerc *widgets.Aerc, args []string) error {
	if !headers.Has("list-unsubscribe") {
		return errors.New("No List-Unsubscribe header found")
	}
	methods := parseUnsubscribeMethods(headers.Get("list-unsubscribe"))
	methods, err := lib.ParseURLList(headers.Get("list-unsubscribe"))
	if err != nil {
		return err
	}
	aerc.Logger().Printf("found %d unsubscribe methods", len(methods))
	for _, method := range methods {
		aerc.Logger().Printf("trying to unsubscribe using %v", method)
@@ -58,28 +60,6 @@ func (Unsubscribe) Execute(aerc *widgets.Aerc, args []string) error {
	return errors.New("no supported unsubscribe methods found")
}

// parseUnsubscribeMethods reads the list-unsubscribe header and parses it as a
// list of angle-bracket <> deliminated URLs. See RFC 2369.
func parseUnsubscribeMethods(header string) (methods []*url.URL) {
	r := bufio.NewReader(strings.NewReader(header))
	for {
		// discard until <
		_, err := r.ReadSlice('<')
		if err != nil {
			return
		}
		// read until <
		m, err := r.ReadSlice('>')
		if err != nil {
			return
		}
		m = m[:len(m)-1]
		if u, err := url.Parse(string(m)); err == nil {
			methods = append(methods, u)
		}
	}
}

func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL) error {
	widget := aerc.SelectedTab().(widgets.ProvidesMessage)
	acct := widget.SelectedAccount()
diff --git a/config/binds.conf b/config/binds.conf
index 760fae1..29722e1 100644
--- a/config/binds.conf
+++ b/config/binds.conf
@@ -39,6 +39,8 @@ rr = :reply -a<Enter>
rq = :reply -aq<Enter>
Rr = :reply<Enter>
Rq = :reply -q<Enter>
rll = :reply -l<Enter>
rlq = :reply -lq<Enter>

c = :cf<space>
$ = :term<space>
@@ -62,6 +64,8 @@ rr = :reply -a<Enter>
rq = :reply -aq<Enter>
Rr = :reply<Enter>
Rq = :reply -q<Enter>
rll = :reply -l<Enter>
rlq = :reply -lq<Enter>

H = :toggle-headers<Enter>
<C-k> = :prev-part<Enter>
diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index 73a4d83..70d1bdb 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -116,13 +116,16 @@ message list, the message in the message viewer, etc).

	*-p*: Pipe just the selected message part, if applicable

*reply* [-aq]
*reply* [-aql]
	Opens the composer to reply to the selected message.

	*-a*: Reply all
	*-a*: Reply all (mutually exclusive with *-l*)

	*-q*: Insert a quoted version of the selected message into the reply editor

	*-l*: Reply just to the address specified in the List-Post header (mutually
	exclusive with *-a*)

*read*
	Marks the marked or selected messages as read.

diff --git a/lib/mailing_list.go b/lib/mailing_list.go
new file mode 100644
index 0000000..3046fcd
--- /dev/null
+++ b/lib/mailing_list.go
@@ -0,0 +1,34 @@
package lib

import (
	"bufio"
	"errors"
	"io"
	"net/url"
	"strings"
)

// ParseURLList parses a list of URLs from a header string as specified in RFC
// 2369, such as the "List-Unsubscribe" header.
func ParseURLList(s string) ([]*url.URL, error) {
	urls := []*url.URL{}
	r := bufio.NewReader(strings.NewReader(s))
	for {
		// discard until <
		_, err := r.ReadSlice('<')
		if err == io.EOF {
			return urls, nil
		} else if err != nil {
			return nil, errors.New("parse URL list: did not find expected <")
		}
		// read until <
		m, err := r.ReadSlice('>')
		if err != nil {
			return nil, errors.New("parse URL list: did not find expected >")
		}
		m = m[:len(m)-1]
		if u, err := url.Parse(string(m)); err == nil {
			urls = append(urls, u)
		}
	}
}
diff --git a/commands/msg/unsubscribe_test.go b/lib/mailing_list_test.go
similarity index 85%
rename from commands/msg/unsubscribe_test.go
rename to lib/mailing_list_test.go
index e4e6f25..08c0b58 100644
--- a/commands/msg/unsubscribe_test.go
+++ b/lib/mailing_list_test.go
@@ -1,10 +1,10 @@
package msg
package lib

import (
	"testing"
)

func TestParseUnsubscribe(t *testing.T) {
func TestParseURLList(t *testing.T) {
	type tc struct {
		hdr      string
		expected []string
@@ -27,7 +27,10 @@ func TestParseUnsubscribe(t *testing.T) {
		}},
	}
	for _, c := range cases {
		result := parseUnsubscribeMethods(c.hdr)
		result, err := ParseURLList(c.hdr)
		if err != nil {
			t.Errorf("error parsing URL list: %v", err)
		}
		if len(result) != len(c.expected) {
			t.Errorf("expected %d methods but got %d", len(c.expected), len(result))
			continue
-- 
2.24.1
Hi Ben,
I'll be off on vacation this week, hence only a short glance over.
Here are a few comments:

On Sun, Jan 05, 2020 at 11:04:45PM -0500, Ben Burwell wrote:

[PATCH 2/2] Add docs for reply -T Export this patch

---
 doc/aerc.1.scd | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd
index 70d1bdb..6dbb7b5 100644
--- a/doc/aerc.1.scd
+++ b/doc/aerc.1.scd
@@ -116,7 +116,7 @@ message list, the message in the message viewer, etc).

	*-p*: Pipe just the selected message part, if applicable

*reply* [-aql]
*reply* [-aql] [-T <template-file>]
	Opens the composer to reply to the selected message.

	*-a*: Reply all (mutually exclusive with *-l*)
@@ -126,6 +126,8 @@ message list, the message in the message viewer, etc).
	*-l*: Reply just to the address specified in the List-Post header (mutually
	exclusive with *-a*)

	*-T*: Use the specified template file for creating the initial message body

*read*
	Marks the marked or selected messages as read.

-- 
2.24.1
View this thread in the archives