~sircmpwn/aerc

Teach the reply command about mailing lists v2 PROPOSED

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

 6 files changed, 155 insertions(+), 69 deletions(-)
> I'm opposed to it, this is an antipattern.
I'm afraid I don't make the list policy / expected behavior.
I simply need to behave as expected.

I realize that this is not a workflow *you* personally like, but others disagree (not referring to me).

Not adding the feature because you don't like it is a bit unfair. It doesn't make matters worse for other people and it's easy to maintain 

Yes  this is your project, you have the override, but please do think about other contributors who'd like to use aerc as well, who may frequent other lists than you do
aerc has always taken a hardline stance on enforcing sane mail behavior.
Otherwise we're just going to allow dozens of competing and incompatible
email quasi-standards to proliferate and email will only get worse and
worse.
Leszek Cimała
Hi,
> aerc has always taken a hardline stance on enforcing sane mail
> behavior. Otherwise we're just going to allow dozens of competing and
> incompatible email quasi-standards to proliferate and email will only
> get worse and worse.
While I understand your point of view. IMHO this idealism will make
some of aerc users miserable and will change nothing. If someone has to
use such mailing list, she/he will have to modify To every time. 

Fact is, that there are already mailing lists which will double
email if you just "reply all" and other mail clients (such as
claws-mail) prevents it by default (like this mail).

If it is really no-go even as non-default behavior. Maybe we should
think how to enable such function as completely optional "plugin"? Just
brainstorm: enabling full mail source processing when replying? This
would enable all sorts of logic/processing without implementing them in
aerc itself (not only related to this particular case).

Leszek
You seem to have engineered it that way, you have two separate email
addresses in the To/Cc's.



          
          
          
        
      

      
      

      

      
      

      

      
    
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/9466/mbox | git am -3
Learn more about email & git

[PATCH v2 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.
---
Since v1:

- Add a replyType type
- Use named return values in getReplyAddresses
- Rename getListReply to getListPost
- Remove rll/rlq binds
- Remove typo comment from lib/mailing_list.go

 commands/msg/reply.go                         | 143 +++++++++++++-----
 commands/msg/unsubscribe.go                   |  28 +---
 doc/aerc.1.scd                                |   7 +-
 lib/mailing_list.go                           |  33 ++++
 .../mailing_list_test.go                      |   9 +-
 5 files changed, 152 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..61f0bc8 100644
--- a/commands/msg/reply.go
+++ b/commands/msg/reply.go
@@ -6,14 +6,24 @@ 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"
)

type replyType int

const (
	replyTypeSender replyType = iota
	replyTypeAll
	replyTypeList
)

type reply struct{}

func init() {
@@ -29,17 +39,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 +60,22 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error {
			quote = true
		case 'T':
			template = opt.Value
		case 'l':
			replyList = true
		}
	}

	rt := replyTypeSender

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

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

@@ -70,37 +94,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, rt, us)
	if err != nil {
		return err
	}

	var subject string
@@ -130,9 +126,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 +177,74 @@ 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, rt replyType,
	us *gomail.Address) (to []string, cc []string, _ error) {
	if rt == replyTypeList {
		listPost, err := getListPost(msg)
		return []string{listPost}, nil, err
	}

	toList := getReplyTo(msg.Envelope)
	for _, addr := range toList {
		to = append(to, addr.Format())
	}

	if rt == 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
}

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

// getListPost searches the list-post header for the first mailto: URL and
// returns the enclosed address, or an error.
func getListPost(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/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..74f0213
--- /dev/null
+++ b/lib/mailing_list.go
@@ -0,0 +1,33 @@
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 <")
		}
		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
I'm not sure I like this feature. I've never wanted to reply like this
to an email. Reply-all will generally do the right thing.

[PATCH v2 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