invites: reply with accept, accept-tentative or decline

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>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Koni Marti 2022-05-24 07:36:07 +02:00 committed by Robin Jarry
parent 6f5c6e148f
commit 62982a9a67
6 changed files with 432 additions and 1 deletions

190
commands/msg/invite.go Normal file
View file

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

View file

@ -112,9 +112,18 @@ message list, the message in the message viewer, etc).
*month*: Messages are stored in folders per year and subfolders per month
*accept*
Accepts an iCalendar meeting invitation.
*accept-tentative*
Accepts an iCalendar meeting invitation tentatively.
*copy* <target>
Copies the selected message to the target folder.
*decline*
Declines an iCalendar meeting invitation.
*delete*
Deletes the selected message.

4
go.mod
View file

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

13
go.sum
View file

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

201
lib/calendar/calendar.go Normal file
View file

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

View file

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