~rjarry/aerc-devel

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
7 4

[PATCH aerc 0/3] reply to invitations

Details
Message ID
<20220519200747.3635813-1-koni.marti@gmail.com>
DKIM signature
pass
Download raw message
This patch set provides the functionality to view/reply to iCalendar
invitations. Replies can either be to accept, tentatively accept or to
decline.

* Patch 1/3 adds an awk filter alternative to view text/calendar mime
  types. To use it, add a new filter for text/calendar in aerc.conf.

* Patch 2/3 extends the composer to add other text parts as
  multipart/alternative. With this we can then add a text/calendar as
  a reply.

* Patch 3/3 introduces the reply commands. The invitation will be parsed
  and used to create an iCal reply which is then added as a
  text/calendar to the composer.


Koni Marti (3):
  filters: awk filter to parse text/calendar
  compose: append text parts
  invites: reply with accept, accept-tentative or decline

 commands/msg/invite.go   | 190 +++++++++++++++++++++++
 filters/calendar         | 328 +++++++++++++++++++++++++++++++++++++++
 go.mod                   |   4 +-
 go.sum                   |  13 ++
 lib/calendar/calendar.go | 201 ++++++++++++++++++++++++
 lib/structure_helpers.go |  16 ++
 widgets/compose.go       |  71 +++++++--
 7 files changed, 809 insertions(+), 14 deletions(-)
 create mode 100644 commands/msg/invite.go
 create mode 100644 filters/calendar
 create mode 100644 lib/calendar/calendar.go

-- 
2.35.1

[PATCH aerc 2/3] compose: append text parts

Details
Message ID
<20220519200747.3635813-3-koni.marti@gmail.com>
In-Reply-To
<20220519200747.3635813-1-koni.marti@gmail.com> (view parent)
DKIM signature
pass
Download raw message
Patch: +58 -13
Append text parts to emails in the composer as multipart/alternative.
Display the mime-type of the parts in the review window.

Signed-off-by: Koni Marti <koni.marti@gmail.com>
---
 widgets/compose.go | 71 +++++++++++++++++++++++++++++++++++++---------
 1 file changed, 58 insertions(+), 13 deletions(-)

diff --git a/widgets/compose.go b/widgets/compose.go
index 8830d9d..467252b 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -30,6 +30,12 @@ import (
	"git.sr.ht/~rjarry/aerc/worker/types"
)

type Part struct {
	MimeType string
	Params   map[string]string
	Body     io.Reader
}

type Composer struct {
	editors map[string]*headerEditor // indexes in lower case (from / cc / bcc)
	header  *mail.Header
@@ -61,6 +67,8 @@ type Composer struct {
	onClose []func(ti *Composer)

	width int

	textParts []*Part
}

func NewComposer(aerc *Aerc, acct *AccountView, conf *config.AercConfig,
@@ -297,6 +305,14 @@ func (c *Composer) AppendContents(reader io.Reader) {
	c.email.Sync()
}

func (c *Composer) AppendPart(mimetype string, params map[string]string, body io.Reader) error {
	if !strings.HasPrefix(mimetype, "text") {
		return fmt.Errorf("can only append text mimetypes")
	}
	c.textParts = append(c.textParts, &Part{MimeType: mimetype, Params: params, Body: body})
	return nil
}

func (c *Composer) AddTemplate(template string, data interface{}) error {
	if template == "" {
		return nil
@@ -592,7 +608,7 @@ func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
}

func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error {
	if len(c.attachments) == 0 && !c.attachKey {
	if len(c.attachments) == 0 && !c.attachKey && len(c.textParts) == 0 {
		// no attachements
		return writeInlineBody(header, c.email, writer)
	} else {
@@ -601,7 +617,14 @@ func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error {
		if err != nil {
			return errors.Wrap(err, "CreateWriter")
		}
		if err := writeMultipartBody(c.email, w); err != nil {
		parts := []*Part{
			&Part{
				MimeType: "text/plain",
				Params:   map[string]string{"Charset": "UTF-8"},
				Body:     c.email,
			},
		}
		if err := writeMultipartBody(append(parts, c.textParts...), w); err != nil {
			return errors.Wrap(err, "writeMultipartBody")
		}
		for _, a := range c.attachments {
@@ -634,24 +657,26 @@ func writeInlineBody(header *mail.Header, body io.Reader, writer io.Writer) erro
}

// write the message body to the multipart message
func writeMultipartBody(body io.Reader, w *mail.Writer) error {
	bh := mail.InlineHeader{}
	bh.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})

func writeMultipartBody(parts []*Part, w *mail.Writer) error {
	bi, err := w.CreateInline()
	if err != nil {
		return errors.Wrap(err, "CreateInline")
	}
	defer bi.Close()

	bw, err := bi.CreatePart(bh)
	if err != nil {
		return errors.Wrap(err, "CreatePart")
	}
	defer bw.Close()
	if _, err := io.Copy(bw, body); err != nil {
		return errors.Wrap(err, "io.Copy")
	for _, part := range parts {
		bh := mail.InlineHeader{}
		bh.SetContentType(part.MimeType, part.Params)
		bw, err := bi.CreatePart(bh)
		if err != nil {
			return errors.Wrap(err, "CreatePart")
		}
		defer bw.Close()
		if _, err := io.Copy(bw, part.Body); err != nil {
			return errors.Wrap(err, "io.Copy")
		}
	}

	return nil
}

@@ -1080,6 +1105,13 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage {
	if composer.attachKey {
		spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
	}
	if len(composer.textParts) > 0 {
		spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
		spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
		for i := 0; i < len(composer.textParts); i++ {
			spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
		}
	}
	// make the last element fill remaining space
	spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})

@@ -1113,6 +1145,7 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage {
		if len(composer.attachments) == 0 && !composer.attachKey {
			grid.AddChild(ui.NewText("(none)",
				uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
			i += 1
		} else {
			for _, a := range composer.attachments {
				grid.AddChild(ui.NewText(a, uiConfig.GetStyle(config.STYLE_DEFAULT))).
@@ -1120,6 +1153,18 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage {
				i += 1
			}
		}
		if len(composer.textParts) > 0 {
			grid.AddChild(ui.NewText("Parts:",
				uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
			i += 1
			grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
			i += 1
			for _, p := range composer.textParts {
				grid.AddChild(ui.NewText(p.MimeType, uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
				i += 1
			}

		}
	}

	return &reviewMessage{
-- 
2.35.1

[PATCH aerc 3/3] invites: reply with accept, accept-tentative or decline

Details
Message ID
<20220519200747.3635813-4-koni.marti@gmail.com>
In-Reply-To
<20220519200747.3635813-1-koni.marti@gmail.com> (view parent)
DKIM signature
pass
Download raw message
Patch: +423 -1
Reply to iCalendar invitations with three commands: :accept,
:accept-tentative or :decline. Parse a text/calendar request, create a
reply and append it to the composer.

Suggested-by: Ondřej Synáček <ondrej@synacek.org>
Signed-off-by: Koni Marti <koni.marti@gmail.com>
---
 commands/msg/invite.go   | 190 ++++++++++++++++++++++++++++++++++++
 go.mod                   |   4 +-
 go.sum                   |  13 +++
 lib/calendar/calendar.go | 201 +++++++++++++++++++++++++++++++++++++++
 lib/structure_helpers.go |  16 ++++
 5 files changed, 423 insertions(+), 1 deletion(-)
 create mode 100644 commands/msg/invite.go
 create mode 100644 lib/calendar/calendar.go

diff --git a/commands/msg/invite.go b/commands/msg/invite.go
new file mode 100644
index 0000000..c15e265
--- /dev/null
+++ b/commands/msg/invite.go
@@ -0,0 +1,190 @@
package msg

import (
	"errors"
	"fmt"
	"io"

	"git.sr.ht/~rjarry/aerc/lib"
	"git.sr.ht/~rjarry/aerc/lib/calendar"
	"git.sr.ht/~rjarry/aerc/lib/format"
	"git.sr.ht/~rjarry/aerc/models"
	"git.sr.ht/~rjarry/aerc/widgets"
	"github.com/emersion/go-message/mail"
)

type invite struct{}

func init() {
	register(invite{})
}

func (invite) Aliases() []string {
	return []string{"accept", "accept-tentative", "decline"}
}

func (invite) Complete(aerc *widgets.Aerc, args []string) []string {
	return nil
}

func (invite) Execute(aerc *widgets.Aerc, args []string) error {

	acct := aerc.SelectedAccount()
	if acct == nil {
		return errors.New("no account selected")
	}
	store := acct.Store()
	if store == nil {
		return errors.New("cannot perform action: messages still loading")
	}
	msg, err := acct.SelectedMessage()
	if err != nil {
		return err
	}

	part := lib.FindCalendartext(msg.BodyStructure, nil)
	if part == nil {
		return fmt.Errorf("no invitation found (missing text/calendar)")
	}

	subject := trimLocalizedRe(msg.Envelope.Subject)
	switch args[0] {
	case "accept":
		subject = "Accepted: " + subject
	case "accept-tentative":
		subject = "Tentatively Accepted: " + subject
	case "decline":
		subject = "Declined: " + subject
	default:
		return fmt.Errorf("no participation status defined")
	}

	conf := acct.AccountConfig()
	from, err := mail.ParseAddress(conf.From)
	if err != nil {
		return err
	}
	var aliases []*mail.Address
	if conf.Aliases != "" {
		aliases, err = mail.ParseAddressList(conf.Aliases)
		if err != nil {
			return err
		}
	}

	// figure out the sending from address if we have aliases
	if len(aliases) != 0 {
		rec := newAddrSet()
		rec.AddList(msg.Envelope.To)
		rec.AddList(msg.Envelope.Cc)
		// test the from first, it has priority over any present alias
		if rec.Contains(from) {
			// do nothing
		} else {
			for _, a := range aliases {
				if rec.Contains(a) {
					from = a
					break
				}
			}
		}
	}

	var (
		to []*mail.Address
	)

	if len(msg.Envelope.ReplyTo) != 0 {
		to = msg.Envelope.ReplyTo
	} else {
		to = msg.Envelope.From
	}

	if !aerc.Config().Compose.ReplyToSelf {
		for i, v := range to {
			if v.Address == from.Address {
				to = append(to[:i], to[i+1:]...)
				break
			}
		}
		if len(to) == 0 {
			to = msg.Envelope.To
		}
	}

	recSet := newAddrSet() // used for de-duping
	recSet.AddList(to)

	h := &mail.Header{}
	h.SetAddressList("from", []*mail.Address{from})
	h.SetSubject(subject)
	h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
	err = setReferencesHeader(h, msg.RFC822Headers)
	if err != nil {
		aerc.PushError(fmt.Sprintf("could not set references: %v", err))
	}
	original := models.OriginalMail{
		From:          format.FormatAddresses(msg.Envelope.From),
		Date:          msg.Envelope.Date,
		RFC822Headers: msg.RFC822Headers,
	}

	handleInvite := func(reader io.Reader) (*calendar.Reply, error) {
		cr, err := calendar.CreateReply(reader, from, args[0])
		if err != nil {
			return nil, err
		}
		for _, org := range cr.Organizers {
			organizer, err := mail.ParseAddress(org)
			if err != nil {
				continue
			}
			if !recSet.Contains(organizer) {
				to = append(to, organizer)
			}
		}
		h.SetAddressList("to", to)
		return cr, nil
	}

	addTab := func(cr *calendar.Reply) error {
		composer, err := widgets.NewComposer(aerc, acct, aerc.Config(),
			acct.AccountConfig(), acct.Worker(), "", h, original)
		if err != nil {
			aerc.PushError("Error: " + err.Error())
			return err
		}

		composer.SetContents(cr.PlainText)
		composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText)
		composer.FocusTerminal()

		tab := aerc.NewTab(composer, subject)
		composer.OnHeaderChange("Subject", func(subject string) {
			if subject == "" {
				tab.Name = "New email"
			} else {
				tab.Name = subject
			}
			tab.Content.Invalidate()
		})

		composer.OnClose(func(c *widgets.Composer) {
			if c.Sent() {
				store.Answered([]uint32{msg.Uid}, true, nil)
			}
		})

		return nil
	}

	store.FetchBodyPart(msg.Uid, part, func(reader io.Reader) {
		if cr, err := handleInvite(reader); err != nil {
			aerc.PushError(err.Error())
			return
		} else {
			addTab(cr)
		}
	})
	return nil
}
diff --git a/go.mod b/go.mod
index fac7bb3..923ec07 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.13
require (
	git.sr.ht/~sircmpwn/getopt v1.0.0
	github.com/ProtonMail/go-crypto v0.0.0-20211221144345-a4f6767435ab
	github.com/arran4/golang-ical v0.0.0-20220517104411-fd89fefb0182 // indirect
	github.com/creack/pty v1.1.17
	github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964
	github.com/ddevault/go-libvterm v0.0.0-20190526194226-b7d861da3810
@@ -32,7 +33,7 @@ require (
	github.com/mitchellh/go-homedir v1.1.0
	github.com/pkg/errors v0.9.1
	github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab
	github.com/stretchr/testify v1.4.0
	github.com/stretchr/testify v1.7.1
	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
	github.com/zenhack/go.notmuch v0.0.0-20211022191430-4d57e8ad2a8b
	golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
@@ -42,6 +43,7 @@ require (
	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
	google.golang.org/appengine v1.6.7 // indirect
	google.golang.org/protobuf v1.27.1 // indirect
	gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 // indirect
)

replace golang.org/x/crypto => github.com/ProtonMail/crypto v0.0.0-20200420072808-71bec3603bf3
diff --git a/go.sum b/go.sum
index 48315d1..92d3246 100644
--- a/go.sum
+++ b/go.sum
@@ -40,6 +40,8 @@ github.com/ProtonMail/crypto v0.0.0-20200420072808-71bec3603bf3/go.mod h1:Pxr7w4
github.com/ProtonMail/go-crypto v0.0.0-20211112122917-428f8eabeeb3/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/ProtonMail/go-crypto v0.0.0-20211221144345-a4f6767435ab h1:5FiL/TCaiKCss/BLMIACDxxadYrx767l9kh0qYX+sLQ=
github.com/ProtonMail/go-crypto v0.0.0-20211221144345-a4f6767435ab/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/arran4/golang-ical v0.0.0-20220517104411-fd89fefb0182 h1:mUsKridvWp4dgfkO/QWtgGwuLtZYpjKgsm15JRRik3o=
github.com/arran4/golang-ical v0.0.0-20220517104411-fd89fefb0182/go.mod h1:BSTTrYHuM12oAL8jDdcmPdw02SBThKYWNFHQlvEG6b0=
github.com/brunnre8/go.notmuch v0.0.0-20201126061756-caa2daf7093c h1:dh58QrW3/S/aCnQPFoeRRE9zMauKooDFd5zh1dLtxXs=
github.com/brunnre8/go.notmuch v0.0.0-20201126061756-caa2daf7093c/go.mod h1:zJtFvR3NinVdmBiLyB4MyXKmqyVfZEb2cK97ISfTgV8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -49,6 +51,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
@@ -166,6 +169,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kyoh86/xdg v1.2.0 h1:CERuT/ShdTDj+A2UaX3hQ3mOV369+Sj+wyn2nIRIIkI=
github.com/kyoh86/xdg v1.2.0/go.mod h1:/mg8zwu1+qe76oTFUBnyS7rJzk7LLC0VGEzJyJ19DHs=
github.com/lithammer/fuzzysearch v1.1.3 h1:+t5SevHLfi3IHcTx7LT3S+od4OcUmjzxD1xmnvtgG38=
@@ -188,6 +192,7 @@ github.com/miolini/datacounter v1.0.2 h1:mGTL0vqEAtH7mwNJS1JIpd6jwTAP6cBQQ2P8apa
github.com/miolini/datacounter v1.0.2/go.mod h1:C45dc2hBumHjDpEU64IqPwR6TDyPVpzOqqRTN7zmBUA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -206,6 +211,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -467,10 +475,15 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99 h1:dbuHpmKjkDzSOMKAWl10QNlgaZUd3V1q99xc81tt2Kc=
gopkg.in/yaml.v3 v3.0.0-20220512140231-539c8e751b99/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/lib/calendar/calendar.go b/lib/calendar/calendar.go
new file mode 100644
index 0000000..d53aec5
--- /dev/null
+++ b/lib/calendar/calendar.go
@@ -0,0 +1,201 @@
package calendar

import (
	"bytes"
	"fmt"
	"io"
	"net/mail"
	"regexp"
	"strings"
	"time"

	ics "github.com/arran4/golang-ical"
)

type Reply struct {
	MimeType     string
	Params       map[string]string
	CalendarText io.ReadWriter
	PlainText    io.ReadWriter
	Organizers   []string
}

func (cr *Reply) AddOrganizer(o string) {
	cr.Organizers = append(cr.Organizers, o)
}

// CreateReply parses a ics request and return a ics reply (RFC 2446, Section 3.2.3)
func CreateReply(reader io.Reader, from *mail.Address, partstat string) (*Reply, error) {

	cr := Reply{
		MimeType: "text/calendar",
		Params: map[string]string{
			"charset": "UTF-8",
			"method":  "REPLY",
		},
		CalendarText: &bytes.Buffer{},
		PlainText:    &bytes.Buffer{},
	}

	var (
		status ics.ParticipationStatus
		action string
	)

	switch partstat {
	case "accept":
		status = ics.ParticipationStatusAccepted
		action = "accepted"
	case "accept-tentative":
		status = ics.ParticipationStatusTentative
		action = "tentatively accepted"
	case "decline":
		status = ics.ParticipationStatusDeclined
		action = "declined"
	default:
		return nil, fmt.Errorf("participation status %s is not implemented", partstat)
	}

	name := from.Name
	if name == "" {
		name = from.Address
	}
	fmt.Fprintf(cr.PlainText, "%s has %s this invitation.", name, action)

	invite, err := parse(reader)
	if err != nil {
		return nil, err
	}

	if ok := invite.request(); !ok {
		return nil, fmt.Errorf("no reply is requested")
	}

	// update invite as a reply
	reply := invite
	reply.SetMethod(ics.MethodReply)
	reply.SetProductId("aerc")

	// check all events
	for _, vevent := range reply.Events() {
		e := event{vevent}

		// check if we should answer
		if err := e.isReplyRequested(from.Address); err != nil {
			return nil, err
		}

		// make sure we send our reply to the meeting organizer
		if organizer := e.GetProperty(ics.ComponentPropertyOrganizer); organizer != nil {
			cr.AddOrganizer(organizer.Value)
		}

		// update attendee participation status
		e.updateAttendees(status, from.Address)

		// update timestamp
		e.SetDtStampTime(time.Now())

		// remove any subcomponents of event
		e.Components = nil
	}

	// keep only timezone and event components
	reply.clean()

	if len(reply.Events()) == 0 {
		return nil, fmt.Errorf("no events to respond to")
	}

	if err := reply.SerializeTo(cr.CalendarText); err != nil {
		return nil, err
	}
	return &cr, nil
}

type calendar struct {
	*ics.Calendar
}

func parse(reader io.Reader) (*calendar, error) {
	// fix capitalized mailto for parsing of ics file
	var sb strings.Builder
	io.Copy(&sb, reader)
	re := regexp.MustCompile("MAILTO:(.+@)")
	str := re.ReplaceAllString(sb.String(), "mailto:${1}")

	// parse calendar
	invite, err := ics.ParseCalendar(strings.NewReader(str))
	if err != nil {
		return nil, err
	}
	return &calendar{invite}, nil
}

func (cal *calendar) request() (ok bool) {
	ok = false
	for i := range cal.CalendarProperties {
		if cal.CalendarProperties[i].IANAToken == string(ics.PropertyMethod) {
			if cal.CalendarProperties[i].Value == string(ics.MethodRequest) {
				ok = true
				return
			}
		}
	}
	return
}

func (cal *calendar) clean() {
	var clean []ics.Component
	for _, comp := range cal.Components {
		switch comp.(type) {
		case *ics.VTimezone, *ics.VEvent:
			clean = append(clean, comp)
		default:
			continue
		}
	}
	cal.Components = clean
}

type event struct {
	*ics.VEvent
}

func (e *event) isReplyRequested(from string) error {
	var present bool = false
	var rsvp bool = false
	for _, a := range e.Attendees() {
		if a.Email() == from {
			present = true
			if r, ok := a.ICalParameters[string(ics.ParameterRsvp)]; ok {
				if len(r) > 0 && strings.ToLower(r[0]) == "true" {
					rsvp = true
				}
			}
		}
	}
	if !present {
		return fmt.Errorf("we are not invited")
	}
	if !rsvp {
		return fmt.Errorf("we don't have to rsvp")
	}
	return nil
}

func (e *event) updateAttendees(status ics.ParticipationStatus, from string) {
	var clean []ics.IANAProperty
	for _, prop := range e.Properties {
		if prop.IANAToken == string(ics.ComponentPropertyAttendee) {
			att := ics.Attendee{prop}
			if att.Email() != from {
				continue
			}
			prop.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{string(status)}
			delete(prop.ICalParameters, string(ics.ParameterRsvp))
		}
		clean = append(clean, prop)
	}
	e.Properties = clean
}
diff --git a/lib/structure_helpers.go b/lib/structure_helpers.go
index 95719dd..ac6950a 100644
--- a/lib/structure_helpers.go
+++ b/lib/structure_helpers.go
@@ -22,6 +22,22 @@ func FindPlaintext(bs *models.BodyStructure, path []int) []int {
	return nil
}

func FindCalendartext(bs *models.BodyStructure, path []int) []int {
	for i, part := range bs.Parts {
		cur := append(path, i+1)
		if strings.ToLower(part.MIMEType) == "text" &&
			strings.ToLower(part.MIMESubType) == "calendar" {
			return cur
		}
		if strings.ToLower(part.MIMEType) == "multipart" {
			if path := FindCalendartext(part, cur); path != nil {
				return path
			}
		}
	}
	return nil
}

func FindFirstNonMultipart(bs *models.BodyStructure, path []int) []int {
	for i, part := range bs.Parts {
		cur := append(path, i+1)
-- 
2.35.1

[PATCH aerc 1/3] filters: awk filter to parse text/calendar

Details
Message ID
<20220519200747.3635813-2-koni.marti@gmail.com>
In-Reply-To
<20220519200747.3635813-1-koni.marti@gmail.com> (view parent)
DKIM signature
pass
Download raw message
Patch: +328 -0
Implement a filter to read text/calendar (ics) data with awk.
Parses multiple events and shows the date recurrence if
available. Awk alternative to the python filter.

Signed-off-by: Koni Marti <koni.marti@gmail.com>
---
 filters/calendar | 328 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 328 insertions(+)
 create mode 100644 filters/calendar

diff --git a/filters/calendar b/filters/calendar
new file mode 100644
index 0000000..a4637b3
--- /dev/null
+++ b/filters/calendar
@@ -0,0 +1,328 @@
# awk filter for aerc to parse text/calendar mime-types
#
# Based on the ical2org.awk script by Eric S Fraga and updated by Guide Van
# Hoecke. Adapted to aerc by Koni Marti <koni.marti@gmail.com>
#

BEGIN {
	UIDS[0];

	people_attending[0];
	people_partstat[0];
	people_rsvp[0];

	# use a colon to separate the type of data line from the actual contents
	FS = ":";

	method = ""
	prodid = ""

	first = 0
}

/^[ ]/ {
	# assumes continuation lines start with a space
	if (indescription) {
		entry = entry gensub("\r", "", "g", gensub("^[ ]", "", 1, $0));
	} else if (insummary) {
		summary = summary gensub("\r", "", "g", gensub("^[ ]", "", 1, $0))
	} else if (inattendee) {
		attendee = attendee gensub("\r", "", "g", gensub("^[ ]", "", 1, $0))
	} else if (inorganizer) {
		organizer = organizer gensub("\r", "", "g", gensub("^[ ]", "", 1, $0))
	} else if (inlocation) {
		location = location unescape(gensub("\r", "", "g", $0), 0);
	}
}

/^BEGIN:VEVENT/ {
	# start of an event: initialize global values used for each event
	date = "";
	entry = ""
	id = ""

	indescription = 0;
	insummary = 0
	inattendee = 0
	inorganizer = 0
	inlocation = 0
	in_alarm = 0

	location = ""
	status = ""
	summary = ""
	attendee = ""
	organizer = ""

	rrend = ""
	rcount = ""
	intfreq = ""

	delete people_attending;
	delete people_partstat;
	delete people_rsvp;

	if (first == 0) {
		first = 1
		if (method != "")
			print     "  METHOD    " method
		if (prodid != "")
			print     "  PRODID    " prodid
		print ""
	}
}

/^BEGIN:VALARM/ {
	in_alarm = 1
}

/^END:VALARM/ {
	in_alarm = 0
}

/^[A-Z]/ {
	if (attendee != "" && inattendee==1)
		add_attendee(attendee)

	if (organizer != "" && inorganizer==1)
		organizer = find_full_name(organizer)

	indescription = 0;
	insummary = 0;
	inattendee = 0;
	inorganizer = 0;
	inlocation = 0;
}

/^DTSTART;VALUE=DATE/ {
	date = datestring($2);
}

/^DTEND;VALUE=DATE/ {
	end_date = datestring($2);
}

/^DTSTART[:;][^V]/ {
	tz = "";
	match($0, /TZID=([^:]*)/, a)
	{
		tz = a[1];
	}
	date = datetimestring($2, tz);
}

/^DTEND[:;][^V]/ {
	tz = "";
	match($0, /TZID=([^:]*)/, a)
	{
		tz = a[1];
	}
	end_date = datetimestring($2, tz);
}

/^RRULE:FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/ {
	# TODO: handle BYDAY values for events that repeat weekly for multiple days
	# (e.g. a "Gym" event)

	# get the d, w, m or y value
	freq = tolower(gensub(/.*FREQ=(.).*/, "\\1", 1, $0))
	# get the interval, and use 1 if none specified
	interval =  $0 ~ /INTERVAL=/ ? gensub(/.*INTERVAL=([0-9]+).*/, "\\1", 1, $0) : 1
	# get the enddate of the rule and use "" if none specified
	rrend = $0 ~ /UNTIL=/ ? datestring(gensub(/.*UNTIL=([0-9]{8}).*/, "\\1", 1, $0)) : ""
	rcount = $0 ~ /COUNT=/ ? gensub(/.*COUNT=([0-9]+).*/, "\\1", 1, $0) : ""
	# build the repetitor value
	intfreq =  " +" interval freq
}

/^DESCRIPTION/ {
	if (!in_alarm) {
		entry = entry gensub("\r", "", "g", gensub($1":", "", 1, $0));
		indescription = 1;
	}
}

/^SUMMARY/ {
	if (!in_alarm) {
		summary = gensub("\r", "", "g", gensub($1":", "", 1, $0));
		insummary = 1;
	}
}

/^UID/ {
	if (!in_alarm) {
		id = gensub("\r", "", "g", $2);
	}
}

/^METHOD/ {
	method = gensub("\r", "", "g", $2);
}

/^PRODID/ {
	prodid = gensub("\r", "", "g", $2);
}

/^ORGANIZER/ {
	organizer = gensub("\r", "", "g", $0);
	inorganizer = 1;
}

/^LOCATION/ {
	location = unescape(gensub("\r", "", "g", $2), 0);
	inlocation = 1;
}

/^STATUS/ {
	status = gensub("\r", "", "g", $2);
}

/^ATTENDEE/ {
	attendee = gensub("\r", "", "g", $0);
	inattendee = 1;
}

/^END:VEVENT/ {
	#output event
	is_duplicate = (id in UIDS);
	if(is_duplicate == 0) {
		print "* SUMMARY   " gensub("^[ ]+", "", "g", unescape(summary, 0))
		# print "  EVENTID   " id
		if(length(location))
			print "  LOCATION  " location
		# if(length(status))
		# 	print "  STATUS    " status
		if(organizer != "")
			print "  ORGANIZER " organizer
		for (cn in people_attending) {
			print "  ATTENDEE  " cn
			partstat = people_partstat[cn]
			if (partstat != "") {
				print "            STATUS " partstat
			}
			rsvp = people_rsvp[cn]
			if (rsvp != "") {
				print "            RSVP   " rsvp
			}
		}
		print "  START     " date
		print "  END       " end_date

		if (intfreq != "") {
			print ""
			print         "  RECURRENCE  " intfreq
			if (rcount != "")
				print "    COUNTS    " rcount
			if (rrend != "")
				print "    END DATE  " rrend

		}

		print ""
		if(length(entry)>1)
			print gensub("^[ ]+", "", "g", unescape(entry, 1));
		UIDS[id] = 1;
	}
}


function unescape(input, preserve_newlines)
{
	ret = gensub("\\\\,", ",", "g",
		     gensub("\\\\;", ";", "g", input))
	if (preserve_newlines)
		ret = gensub("\\\\n", "\n", "g", ret)
	else
		ret = gensub("\\\\n", " ", "g", ret)
	return ret
}


function datetimestring(input, tz)
{
	spec  = match(input, "([0-9]{4})([0-9]{2})([0-9]{2})T([0-9]{2})([0-9]{2})([0-9]{2}).*[\r]*", a);
	year = a[1]
	month = a[2]
	day = a[3]
	hour = a[4]
	min = a[5]
	sec = a[6]

	stamp = mktime(year" "month" "day" "hour" "min" "sec);

	if (input ~ /[0-9]{8}T[0-9]{6}Z/ ) {
		tz = "UTC"
	}

	return strftime("%Y-%m-%d %a %H:%M", stamp)" "tz;
}


function datestring(input)
{
	spec = gensub("([0-9]{4})([0-9]{2})([0-9]{2}).*[\r]*", "\\1 \\2 \\3 00 00 00", "g", input);

	stamp = mktime(spec);

	return strftime("%Y-%m-%d %a", stamp);
}

function add_attendee(attendee)
{
	CN = find_full_name(attendee)
	if (CN != "")
		people_attending[CN] = 1;

	if (CN != "") {
		match(attendee, /PARTSTAT=([^;]+)/, m)
		{
			people_partstat[CN] = m[1]
		}
		match(attendee, /RSVP=([^;]+)/, m)
		{
			people_rsvp[CN] = m[1]
		}
	}
}

function extract_email(line)
{
	email = ""
	match(line,/:[ ]*(mailto|MAILTO):([^;]+)/, m)
	{
		email = m[2]
	}
	return email
}

function extract_name(line)
{
	name = ""
	match(line,/CN="?([^;:"]+)/, m)
	{
		name = m[1]
	}
	return name
}

function find_full_name(line)
{
	name = extract_name(line)
	email = extract_email(line)

	if (name == "") {
		if (email == "") {
			return ""
		} else {
			return name
		}
	}

	if (email == "")
		return name

	if (email == name)
		return "<"email">"

	return name" <"email">"
}
-- 
2.35.1

[aerc/patches] build failed

builds.sr.ht <builds@sr.ht>
Details
Message ID
<CK40J6TB77SF.9IU4IUPBOQ50@cirno2>
In-Reply-To
<20220519200747.3635813-2-koni.marti@gmail.com> (view parent)
DKIM signature
missing
Download raw message
aerc/patches: FAILED in 6m38s

[reply to invitations][0] from [Koni Marti][1]

[0]: https://lists.sr.ht/~rjarry/aerc-devel/patches/32399
[1]: koni.marti@gmail.com

✓ #762126 SUCCESS aerc/patches/debian-stable.yml https://builds.sr.ht/~rjarry/job/762126
✓ #762125 SUCCESS aerc/patches/alpine-edge.yml   https://builds.sr.ht/~rjarry/job/762125
✓ #762128 SUCCESS aerc/patches/openbsd.yml       https://builds.sr.ht/~rjarry/job/762128
✗ #762127 FAILED  aerc/patches/fedora-latest.yml https://builds.sr.ht/~rjarry/job/762127

Re: [PATCH aerc 3/3] invites: reply with accept, accept-tentative or decline

Details
Message ID
<CK4FAI54OR74.3AXAXE0R91PBB@PB-Admins-MacBook-Pro-2.local>
In-Reply-To
<20220519200747.3635813-4-koni.marti@gmail.com> (view parent)
DKIM signature
missing
Download raw message
On Thu May 19, 2022 at 10:07 PM CEST, Koni Marti wrote:
> Reply to iCalendar invitations with three commands: :accept,
> :accept-tentative or :decline. Parse a text/calendar request, create a
> reply and append it to the composer.
>
> Suggested-by: Ondřej Synáček <ondrej@synacek.org>
> Signed-off-by: Koni Marti <koni.marti@gmail.com>

This is very cool! Thanks for considering my request. Can't help with code review
though as I'm not skilled in Go.

Re: [PATCH aerc 3/3] invites: reply with accept, accept-tentative or decline

Details
Message ID
<CK4TL7WZQYXL.H7WVX943VSUV@TimBook-Arch>
In-Reply-To
<20220519200747.3635813-4-koni.marti@gmail.com> (view parent)
DKIM signature
pass
Download raw message
Works well for me! iCloud invitations don't work, but it's because of
how they implement - you can't respond to their emails, you have to
click the links. Tested accept, tentative, and declines on O365,
Fastmail, and Gmail.

I think we should add the filter as a default in the config file.

Tested-by: Tim Culverhouse <tim@timculverhouse.com>

Re: [PATCH aerc 3/3] invites: reply with accept, accept-tentative or decline

Details
Message ID
<CK4WEVS4JAIZ.2KU8RMXTN3FC1@moon2>
In-Reply-To
<CK4TL7WZQYXL.H7WVX943VSUV@TimBook-Arch> (view parent)
DKIM signature
pass
Download raw message
On Fri May 20, 2022 at 9:02 PM CEST, Tim Culverhouse wrote:
> Works well for me! iCloud invitations don't work, but it's because of
> how they implement - you can't respond to their emails, you have to
> click the links. Tested accept, tentative, and declines on O365,
> Fastmail, and Gmail.

Thanks for checking iCloud as well. I was not aware that they do their
own thing.. Might be something to look into for a next patch.

>
> I think we should add the filter as a default in the config file.
>
> Tested-by: Tim Culverhouse <tim@timculverhouse.com>

I'll send a v2 in a bit but just with minor edits.  Thanks for testing!
Reply to thread Export thread (mbox)