~rjarry/aerc

030f39043628f01b174ebb11595a4e74da95f0b3 — Ben Burwell 2 years ago 1bb1a80
Add unsubscribe command

The unsubscribe command, available when in a message viewer context,
enables users to easily unsubscribe from mailing lists.

When the command is executed, aerc looks for a List-Unsubscribe header
as defined in RFC 2369. If found, aerc will attempt to present the user
with a suitable interface for completing the request. Currently, mailto
and http(s) URLs are supported. In the case of a HTTP(S) URL, aerc will
open the link in a browser. For mailto links, a new composer tab will be
opened with a message filled out according to the URL. The message is
not sent automatically in order to provide the user a chance to review
it first.

Closes #101
3 files changed, 150 insertions(+), 0 deletions(-)

A commands/msg/unsubscribe.go
A commands/msg/unsubscribe_test.go
M doc/aerc.1.scd
A commands/msg/unsubscribe.go => commands/msg/unsubscribe.go +103 -0
@@ 0,0 1,103 @@
package msg

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

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

// Unsubscribe helps people unsubscribe from mailing lists by way of the
// List-Unsubscribe header.
type Unsubscribe struct{}

func init() {
	register(Unsubscribe{})
}

// Aliases returns a list of aliases for the :unsubscribe command
func (Unsubscribe) Aliases() []string {
	return []string{"unsubscribe"}
}

// Complete returns a list of completions
func (Unsubscribe) Complete(aerc *widgets.Aerc, args []string) []string {
	return nil
}

// Execute runs the Unsubscribe command
func (Unsubscribe) Execute(aerc *widgets.Aerc, args []string) error {
	if len(args) != 1 {
		return errors.New("Usage: unsubscribe")
	}
	widget := aerc.SelectedTab().(widgets.ProvidesMessage)
	headers := widget.SelectedMessage().RFC822Headers
	if !headers.Has("list-unsubscribe") {
		return errors.New("No List-Unsubscribe header found")
	}
	methods := parseUnsubscribeMethods(headers.Get("list-unsubscribe"))
	aerc.Logger().Printf("found %d unsubscribe methods", len(methods))
	for _, method := range methods {
		aerc.Logger().Printf("trying to unsubscribe using %v", method)
		switch method.Scheme {
		case "mailto":
			return unsubscribeMailto(aerc, method)
		case "http", "https":
			return unsubscribeHTTP(method)
		default:
			aerc.Logger().Printf("skipping unrecognized scheme: %s", method.Scheme)
		}
	}
	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()
	composer := widgets.NewComposer(aerc.Config(), acct.AccountConfig(),
		acct.Worker())
	composer.Defaults(map[string]string{
		"To":      u.Opaque,
		"Subject": u.Query().Get("subject"),
	})
	composer.SetContents(strings.NewReader(u.Query().Get("body")))
	tab := aerc.NewTab(composer, "unsubscribe")
	composer.OnSubjectChange(func(subject string) {
		if subject == "" {
			tab.Name = "unsubscribe"
		} else {
			tab.Name = subject
		}
		tab.Content.Invalidate()
	})
	return nil
}

func unsubscribeHTTP(u *url.URL) error {
	return lib.OpenFile(u.String())
}

A commands/msg/unsubscribe_test.go => commands/msg/unsubscribe_test.go +41 -0
@@ 0,0 1,41 @@
package msg

import (
	"testing"
)

func TestParseUnsubscribe(t *testing.T) {
	type tc struct {
		hdr      string
		expected []string
	}
	cases := []*tc{
		&tc{"", []string{}},
		&tc{"invalid", []string{}},
		&tc{"<https://example.com>, <http://example.com>", []string{
			"https://example.com", "http://example.com",
		}},
		&tc{"<https://example.com> is a URL", []string{
			"https://example.com",
		}},
		&tc{"<mailto:user@host?subject=unsubscribe>, <https://example.com>",
			[]string{
				"mailto:user@host?subject=unsubscribe", "https://example.com",
			}},
		&tc{"<>, <https://example> ", []string{
			"", "https://example",
		}},
	}
	for _, c := range cases {
		result := parseUnsubscribeMethods(c.hdr)
		if len(result) != len(c.expected) {
			t.Errorf("expected %d methods but got %d", len(c.expected), len(result))
			continue
		}
		for idx := 0; idx < len(result); idx++ {
			if result[idx].String() != c.expected[idx] {
				t.Errorf("expected %v but got %v", c.expected[idx], result[idx])
			}
		}
	}
}

M doc/aerc.1.scd => doc/aerc.1.scd +6 -0
@@ 85,6 85,12 @@ message list, the message in the message viewer, etc).
*unread*
	Marks the selected message as unread.

*unsubscribe*
	Attempt to automatically unsubscribe the user from the mailing list through
	use of the List-Unsubscribe header. If supported, aerc may open a compose
	window pre-filled with the unsubscribe information or open the unsubscribe
	URL in a web browser.

## MESSAGE LIST COMMANDS

*cf* <folder>