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/doc/aerc.1.scd b/doc/aerc.1.scd index 0b33cee..0c071ea 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -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* Copies the selected message to the target folder. +*decline* + Declines an iCalendar meeting invitation. + *delete* Deletes the selected message. 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)