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:
parent
6f5c6e148f
commit
62982a9a67
6 changed files with 432 additions and 1 deletions
190
commands/msg/invite.go
Normal file
190
commands/msg/invite.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
*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>
|
*copy* <target>
|
||||||
Copies the selected message to the target folder.
|
Copies the selected message to the target folder.
|
||||||
|
|
||||||
|
*decline*
|
||||||
|
Declines an iCalendar meeting invitation.
|
||||||
|
|
||||||
*delete*
|
*delete*
|
||||||
Deletes the selected message.
|
Deletes the selected message.
|
||||||
|
|
||||||
|
|
4
go.mod
4
go.mod
|
@ -5,6 +5,7 @@ go 1.13
|
||||||
require (
|
require (
|
||||||
git.sr.ht/~sircmpwn/getopt v1.0.0
|
git.sr.ht/~sircmpwn/getopt v1.0.0
|
||||||
github.com/ProtonMail/go-crypto v0.0.0-20211221144345-a4f6767435ab
|
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/creack/pty v1.1.17
|
||||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964
|
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964
|
||||||
github.com/ddevault/go-libvterm v0.0.0-20190526194226-b7d861da3810
|
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/mitchellh/go-homedir v1.1.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab
|
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/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||||
github.com/zenhack/go.notmuch v0.0.0-20211022191430-4d57e8ad2a8b
|
github.com/zenhack/go.notmuch v0.0.0-20211022191430-4d57e8ad2a8b
|
||||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
|
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
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/protobuf v1.27.1 // 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
|
replace golang.org/x/crypto => github.com/ProtonMail/crypto v0.0.0-20200420072808-71bec3603bf3
|
||||||
|
|
13
go.sum
13
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-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 h1:5FiL/TCaiKCss/BLMIACDxxadYrx767l9kh0qYX+sLQ=
|
||||||
github.com/ProtonMail/go-crypto v0.0.0-20211221144345-a4f6767435ab/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
|
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 h1:dh58QrW3/S/aCnQPFoeRRE9zMauKooDFd5zh1dLtxXs=
|
||||||
github.com/brunnre8/go.notmuch v0.0.0-20201126061756-caa2daf7093c/go.mod h1:zJtFvR3NinVdmBiLyB4MyXKmqyVfZEb2cK97ISfTgV8=
|
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=
|
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/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/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/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 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
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/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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
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 h1:CERuT/ShdTDj+A2UaX3hQ3mOV369+Sj+wyn2nIRIIkI=
|
||||||
github.com/kyoh86/xdg v1.2.0/go.mod h1:/mg8zwu1+qe76oTFUBnyS7rJzk7LLC0VGEzJyJ19DHs=
|
github.com/kyoh86/xdg v1.2.0/go.mod h1:/mg8zwu1+qe76oTFUBnyS7rJzk7LLC0VGEzJyJ19DHs=
|
||||||
github.com/lithammer/fuzzysearch v1.1.3 h1:+t5SevHLfi3IHcTx7LT3S+od4OcUmjzxD1xmnvtgG38=
|
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/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 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
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.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 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
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=
|
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 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 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-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/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.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
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.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-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-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/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
201
lib/calendar/calendar.go
Normal 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
|
||||||
|
}
|
|
@ -22,6 +22,22 @@ func FindPlaintext(bs *models.BodyStructure, path []int) []int {
|
||||||
return nil
|
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 {
|
func FindFirstNonMultipart(bs *models.BodyStructure, path []int) []int {
|
||||||
for i, part := range bs.Parts {
|
for i, part := range bs.Parts {
|
||||||
cur := append(path, i+1)
|
cur := append(path, i+1)
|
||||||
|
|
Loading…
Reference in a new issue