mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-03-02 18:23:33 +01:00
worker: add jmap support
Add support for JMAP backends. This is on par with IMAP features with some additions specific to JMAP: * tagging * sending emails This makes use of git.sr.ht/~rockorager/go-jmap for the low level interaction with the JMAP server. The transport is JSON over HTTPS. For now, only oauthbearer with token is supported. If this proves useful, we may need to file for an official three-legged oauth support at JMAP providers. I have tested most features and this seems to be reliable. There are some quirks with the use-labels option. Especially when moving and deleting messages from the "All mail" virtual folder (see aerc-jmap(5)). Overall, the user experience is nice and there are a lot less background updates issues than with IMAP (damn IDLE mode hanging after restoring from sleep). I know that not everyone has access to a JMAP provider. For those interested, there are at least these two commercial offerings: https://www.fastmail.com/ https://www.topicbox.com/ And, if you host your own mail, you can use a JMAP capable server: https://stalw.art/jmap/ https://www.cyrusimap.org/imap/download/installation/http/jmap.html Link: https://www.rfc-editor.org/rfc/rfc8620.html Link: https://www.rfc-editor.org/rfc/rfc8621.html Signed-off-by: Robin Jarry <robin@jarry.cc> Tested-by: Tim Culverhouse <tim@timculverhouse.com>
This commit is contained in:
@@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- IMAP now uses the delimiter advertised by the server
|
||||
- Completions for `:mkdir`
|
||||
- `carddav-query` utility to use for `address-book-cmd`.
|
||||
- JMAP support.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -30,6 +30,7 @@ DOCS := \
|
||||
aerc-binds.5 \
|
||||
aerc-config.5 \
|
||||
aerc-imap.5 \
|
||||
aerc-jmap.5 \
|
||||
aerc-maildir.5 \
|
||||
aerc-sendmail.5 \
|
||||
aerc-notmuch.5 \
|
||||
@@ -127,6 +128,7 @@ install: $(DOCS) aerc wrap colorize
|
||||
install -m644 aerc-binds.5 $(DESTDIR)$(MANDIR)/man5/aerc-binds.5
|
||||
install -m644 aerc-config.5 $(DESTDIR)$(MANDIR)/man5/aerc-config.5
|
||||
install -m644 aerc-imap.5 $(DESTDIR)$(MANDIR)/man5/aerc-imap.5
|
||||
install -m644 aerc-jmap.5 $(DESTDIR)$(MANDIR)/man5/aerc-jmap.5
|
||||
install -m644 aerc-maildir.5 $(DESTDIR)$(MANDIR)/man5/aerc-maildir.5
|
||||
install -m644 aerc-sendmail.5 $(DESTDIR)$(MANDIR)/man5/aerc-sendmail.5
|
||||
install -m644 aerc-notmuch.5 $(DESTDIR)$(MANDIR)/man5/aerc-notmuch.5
|
||||
@@ -164,6 +166,7 @@ checkinstall:
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-binds.5
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-config.5
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-imap.5
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-jmap.5
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-notmuch.5
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-sendmail.5
|
||||
test -e $(DESTDIR)$(MANDIR)/man5/aerc-smtp.5
|
||||
@@ -181,6 +184,7 @@ uninstall:
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-binds.5
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-config.5
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-imap.5
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-jmap.5
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-maildir.5
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-sendmail.5
|
||||
$(RM) $(DESTDIR)$(MANDIR)/man5/aerc-notmuch.5
|
||||
|
||||
@@ -35,6 +35,7 @@ Also available as man pages:
|
||||
- [aerc-binds(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-binds.5.scd)
|
||||
- [aerc-config(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-config.5.scd)
|
||||
- [aerc-imap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-imap.5.scd)
|
||||
- [aerc-jmap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-jmap.5.scd)
|
||||
- [aerc-maildir(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-maildir.5.scd)
|
||||
- [aerc-notmuch(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-notmuch.5.scd)
|
||||
- [aerc-search(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-search.1.scd)
|
||||
|
||||
@@ -80,6 +80,7 @@ func (RemoveDir) Execute(aerc *widgets.Aerc, args []string) error {
|
||||
|
||||
acct.Worker().PostAction(&types.RemoveDirectory{
|
||||
Directory: curDir,
|
||||
Quiet: force,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
|
||||
@@ -174,6 +174,8 @@ func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
|
||||
fallthrough
|
||||
case "smtps":
|
||||
sender, err = newSmtpSender(ctx)
|
||||
case "jmap":
|
||||
sender, err = newJmapSender(composer, header, ctx)
|
||||
case "":
|
||||
sender, err = newSendmailSender(ctx)
|
||||
default:
|
||||
@@ -186,7 +188,7 @@ func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
|
||||
|
||||
var writer io.Writer = sender
|
||||
|
||||
if config.CopyTo != "" {
|
||||
if config.CopyTo != "" && ctx.scheme != "jmap" {
|
||||
writer = io.MultiWriter(writer, ©Buf)
|
||||
}
|
||||
err = composer.WriteMessage(header, writer)
|
||||
@@ -210,7 +212,7 @@ func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
|
||||
aerc.NewTab(composer, tabName)
|
||||
return
|
||||
}
|
||||
if config.CopyTo != "" {
|
||||
if config.CopyTo != "" && ctx.scheme != "jmap" {
|
||||
aerc.PushStatus("Copying to "+config.CopyTo, 10*time.Second)
|
||||
errch := copyToSent(composer.Worker(), config.CopyTo,
|
||||
copyBuf.Len(), ©Buf)
|
||||
@@ -512,6 +514,36 @@ func connectSmtps(host string) (*smtp.Client, error) {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func newJmapSender(
|
||||
composer *widgets.Composer, header *mail.Header, ctx sendCtx,
|
||||
) (io.WriteCloser, error) {
|
||||
var writer io.WriteCloser
|
||||
done := make(chan error)
|
||||
|
||||
composer.Worker().PostAction(
|
||||
&types.StartSendingMessage{Header: header},
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
return
|
||||
case *types.Unsupported:
|
||||
done <- fmt.Errorf("unsupported by worker")
|
||||
case *types.Error:
|
||||
done <- msg.Error
|
||||
case *types.MessageWriter:
|
||||
writer = msg.Writer
|
||||
default:
|
||||
done <- fmt.Errorf("unexpected worker message: %#v", msg)
|
||||
}
|
||||
close(done)
|
||||
},
|
||||
)
|
||||
|
||||
err := <-done
|
||||
|
||||
return writer, err
|
||||
}
|
||||
|
||||
func copyToSent(worker *types.Worker, dest string,
|
||||
n int, msg io.Reader,
|
||||
) <-chan error {
|
||||
|
||||
@@ -14,6 +14,7 @@ var pages = []string{
|
||||
"binds",
|
||||
"config",
|
||||
"imap",
|
||||
"jmap",
|
||||
"notmuch",
|
||||
"search",
|
||||
"sendmail",
|
||||
|
||||
@@ -173,6 +173,7 @@ Note that many of these configuration options are written for you, such as
|
||||
See each protocol's man page for more details:
|
||||
|
||||
- *aerc-imap*(5)
|
||||
- *aerc-jmap*(5)
|
||||
- *aerc-maildir*(5)
|
||||
- *aerc-notmuch*(5)
|
||||
|
||||
@@ -212,8 +213,8 @@ Note that many of these configuration options are written for you, such as
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*aerc*(1) *aerc-config*(5) *aerc-imap*(5) *aerc-maildir*(5) *aerc-notmuch*(5)
|
||||
*aerc-sendmail*(5) *aerc-smtp*(5)
|
||||
*aerc*(1) *aerc-config*(5) *aerc-imap*(5) *aerc-jmap*(5) *aerc-maildir*(5)
|
||||
*aerc-notmuch*(5) *aerc-sendmail*(5) *aerc-smtp*(5)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
|
||||
@@ -924,9 +924,9 @@ These options are configured in the *[templates]* section of _aerc.conf_.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*aerc*(1) *aerc-accounts*(5) *aerc-binds*(5) *aerc-imap*(5) *aerc-maildir*(5)
|
||||
*aerc-notmuch*(5) *aerc-templates*(7) *aerc-sendmail*(5) *aerc-smtp*(5)
|
||||
*aerc-stylesets*(7) *carddav-query*(1)
|
||||
*aerc*(1) *aerc-accounts*(5) *aerc-binds*(5) *aerc-imap*(5) *aerc-jmap*(5)
|
||||
*aerc-maildir*(5) *aerc-notmuch*(5) *aerc-templates*(7) *aerc-sendmail*(5)
|
||||
*aerc-smtp*(5) *aerc-stylesets*(7) *carddav-query*(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
|
||||
148
doc/aerc-jmap.5.scd
Normal file
148
doc/aerc-jmap.5.scd
Normal file
@@ -0,0 +1,148 @@
|
||||
AERC-JMAP(5)
|
||||
|
||||
# NAME
|
||||
|
||||
aerc-jmap - JMAP configuration for *aerc*(1)
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
aerc implements the JMAP protocol as specified by RFCs 8620 and 8621.
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
JMAP accounts currently are not supported with the *:new-account* command and
|
||||
must be added manually.
|
||||
|
||||
In _accounts.conf_ (see *aerc-accounts*(5)), the following JMAP-specific options
|
||||
are available:
|
||||
|
||||
*source* = _<scheme>_://[_<username>_][_:<password>@_]_<hostname>_[_:<port>_]/_<path>_
|
||||
Remember that all fields must be URL encoded. The _@_ symbol, when URL
|
||||
encoded, is _%40_.
|
||||
|
||||
_<hostname>_[_:<port>_]/_<path>_ is the HTTPS JMAP session resource as
|
||||
specified in RFC 8620 section 2 without the leading _https://_ scheme.
|
||||
|
||||
Possible values of _<scheme>_ are:
|
||||
|
||||
_jmap_
|
||||
JMAP over HTTPS using Basic authentication.
|
||||
|
||||
_jmap+oauthbearer_
|
||||
JMAP over HTTPS using OAUTHBEARER authentication
|
||||
|
||||
The username is ignored any may be left empty. If specifying the
|
||||
password, make sure to prefix it with _:_ to make it explicit
|
||||
that the username is empty. Or set the username to any random
|
||||
value. E.g.:
|
||||
|
||||
```
|
||||
source = jmap+oauthbearer://:s3cr3t@example.com/jmap/session
|
||||
source = jmap+oauthbearer://me:s3cr3t@example.com/jmap/session
|
||||
```
|
||||
|
||||
Your source credentials must have the _urn:ietf:params:jmap:mail_
|
||||
capability.
|
||||
|
||||
*source-cred-cmd* = _<command>_
|
||||
Specifies the command to run to get the password for the JMAP account.
|
||||
This command will be run using _sh -c command_. If a password is
|
||||
specified in the *source* option, the password will take precedence over
|
||||
this command.
|
||||
|
||||
Example:
|
||||
source-cred-cmd = pass hostname/username
|
||||
|
||||
*outgoing* = _jmap://_
|
||||
The JMAP connection can also be used to send emails. No need to repeat
|
||||
the URL nor any credentials. Just the URL scheme will be enough.
|
||||
|
||||
Your source credentials must have the _urn:ietf:params:jmap:submission_
|
||||
capability.
|
||||
|
||||
*cache-state* = _true_|_false_
|
||||
Cache all email state (mailboxes, email headers, mailbox contents, email
|
||||
flags, etc.) on disk in a levelDB database located in folder
|
||||
_~/.cache/aerc/<account>/state_.
|
||||
|
||||
The cached data should remain small, in the order of a few megabytes,
|
||||
even for very large email stores. Aerc will make its best to purge
|
||||
deleted/outdated information. It is safe to delete that folder when aerc
|
||||
is not running and it will be recreated from scratch on next startup.
|
||||
|
||||
Default: _false_
|
||||
|
||||
*cache-blobs* = _true_|_false_
|
||||
Cache all downloaded email bodies and attachments on disk as individual
|
||||
files in _~/.cache/aerc/<account>/blobs/<xx>/<blob_id>_ (where _<xx>_ is
|
||||
a subfolder named after the last two characters of _<blob_id>_).
|
||||
|
||||
Aerc will not purge the cached blobs automatically. Even when their
|
||||
related emails are destroyed permanently from the server. If required,
|
||||
you may want to run some periodic cleanup based on file creation date in
|
||||
a crontab, e.g.:
|
||||
|
||||
@daily find ~/.cache/aerc/foo/blobs -type f -mtime +30 -delete
|
||||
|
||||
Default: _false_
|
||||
|
||||
*use-labels* = _true_|_false_
|
||||
If set to _true_, mailboxes with the _archive_ role (usually _Archive_)
|
||||
will be hidden from the directory list and replaced by an *all-mail*
|
||||
virtual folder. The name of that folder can be configured via the
|
||||
*all-mail* setting.
|
||||
|
||||
*:archive flat* may still be used to effectively "tag" messages with the
|
||||
hidden _Archive_ mailbox so that they appear in the *all-mail* virtual
|
||||
folder. When the *all-mail* virtual folder is selected, *:archive flat*
|
||||
should not be used and will have no effect. The messages will be grayed
|
||||
out but will never be refreshed until aerc is restarted.
|
||||
|
||||
Also, this enables support for the *:modify-labels* (alias *:tag*)
|
||||
command.
|
||||
|
||||
Default: _false_
|
||||
|
||||
*all-mail* = _<name>_
|
||||
Name of the virtual folder that replaces the role=_archive_ mailbox when
|
||||
*use-labels* = _true_.
|
||||
|
||||
Default: _All mail_
|
||||
|
||||
*server-ping* = _<duration>_
|
||||
Interval the server should ping the client at when monitoring for email
|
||||
changes. The server may choose to ignore this value. By default, no ping
|
||||
will be requested from the server.
|
||||
|
||||
See https://pkg.go.dev/time#ParseDuration.
|
||||
|
||||
# NOTES
|
||||
|
||||
JMAP messages can be seen as "labels" or "tags". Every message must belong to
|
||||
one or more mailboxes (folders in aerc). Each mailbox has a "role" as described
|
||||
in _https://www.iana.org/assignments/imap-mailbox-name-attributes/_.
|
||||
|
||||
When deleting messages that belong only to the selected mailbox, aerc will
|
||||
attempt to "move" these messages to a mailbox with the _trash_ role. If it
|
||||
cannot find such mailbox or if the selected mailbox is the _trash_ mailbox, it
|
||||
will effectively destroy the messages from the server.
|
||||
|
||||
*:delete* removes messages from the selected mailbox and effectively does the
|
||||
same thing than *:tag -<selected_folder>*.
|
||||
|
||||
*:cp <foo>* is an alias for *:tag <foo>* or *:tag +<foo>*.
|
||||
|
||||
*:mv <foo>* is a compound of *:delete* and *:mv* and can be seen as an alias of
|
||||
*:tag -<selected_folder> +<foo>*.
|
||||
|
||||
*:archive flat* is an alias for *:tag -<selected_folder> +<archive>*.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*aerc*(1) *aerc-accounts*(5)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Originally created by Drew DeVault <sir@cmpwn.com> and maintained by Robin
|
||||
Jarry <robin@jarry.cc> who is assisted by other open source contributors. For
|
||||
more information about aerc development, see https://sr.ht/~rjarry/aerc/.
|
||||
@@ -628,9 +628,9 @@ in _aerc.conf_.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
*aerc-config*(5) *aerc-imap*(5) *aerc-notmuch*(5) *aerc-smtp*(5) *aerc-maildir*(5)
|
||||
*aerc-sendmail*(5) *aerc-search*(1) *aerc-stylesets*(7) *aerc-templates*(7)
|
||||
*aerc-accounts*(5) *aerc-binds*(5) *aerc-tutorial*(7)
|
||||
*aerc-config*(5) *aerc-imap*(5) *aerc-jmap*(5) *aerc-notmuch*(5) *aerc-smtp*(5)
|
||||
*aerc-maildir*(5) *aerc-sendmail*(5) *aerc-search*(1) *aerc-stylesets*(7)
|
||||
*aerc-templates*(7) *aerc-accounts*(5) *aerc-binds*(5) *aerc-tutorial*(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
|
||||
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module git.sr.ht/~rjarry/aerc
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
git.sr.ht/~rockorager/go-jmap v0.3.0
|
||||
git.sr.ht/~rockorager/tcell-term v0.8.0
|
||||
git.sr.ht/~sircmpwn/getopt v1.0.0
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230417170513-8ee5748c52b5
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
||||
git.sr.ht/~rockorager/go-jmap v0.3.0 h1:h2WuPcNyXRYFg9+W2HGf/mzIqC6ISy9EaS/BGa7Z5RY=
|
||||
git.sr.ht/~rockorager/go-jmap v0.3.0/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY=
|
||||
git.sr.ht/~rockorager/tcell-term v0.8.0 h1:jAAzWgTAzMz8uMXbOLZd5WgV7qmb6zRE0Z7HUrDdVPs=
|
||||
git.sr.ht/~rockorager/tcell-term v0.8.0/go.mod h1:Snxh5CrziiA2CjyLOZ6tGAg5vMPlE+REMWT3rtKuyyQ=
|
||||
git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc=
|
||||
|
||||
45
worker/jmap/cache/blob.go
vendored
Normal file
45
worker/jmap/cache/blob.go
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
)
|
||||
|
||||
func (c *JMAPCache) GetBlob(id jmap.ID) ([]byte, error) {
|
||||
fpath := c.blobPath(id)
|
||||
if fpath == "" {
|
||||
return nil, notfound
|
||||
}
|
||||
return os.ReadFile(fpath)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutBlob(id jmap.ID, buf []byte) error {
|
||||
fpath := c.blobPath(id)
|
||||
if fpath == "" {
|
||||
return nil
|
||||
}
|
||||
_ = os.MkdirAll(path.Dir(fpath), 0o700)
|
||||
return os.WriteFile(fpath, buf, 0o600)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteBlob(id jmap.ID) error {
|
||||
fpath := c.blobPath(id)
|
||||
if fpath == "" {
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Remove(path.Dir(fpath))
|
||||
}()
|
||||
return os.Remove(fpath)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) blobPath(id jmap.ID) string {
|
||||
if c.blobsDir == "" {
|
||||
return ""
|
||||
}
|
||||
name := string(id)
|
||||
sub := name[len(name)-2:]
|
||||
return path.Join(c.blobsDir, sub, name)
|
||||
}
|
||||
79
worker/jmap/cache/cache.go
vendored
Normal file
79
worker/jmap/cache/cache.go
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
)
|
||||
|
||||
type JMAPCache struct {
|
||||
mem map[string][]byte
|
||||
file *leveldb.DB
|
||||
blobsDir string
|
||||
}
|
||||
|
||||
func NewJMAPCache(state, blobs bool, accountName string) (*JMAPCache, error) {
|
||||
c := new(JMAPCache)
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir, err = homedir.Expand("~/.cache")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if state {
|
||||
dir := path.Join(cacheDir, "aerc", accountName, "state")
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
c.file, err = leveldb.OpenFile(dir, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
c.mem = make(map[string][]byte)
|
||||
}
|
||||
if blobs {
|
||||
c.blobsDir = path.Join(cacheDir, "aerc", accountName, "blobs")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var notfound = errors.New("key not found")
|
||||
|
||||
func (c *JMAPCache) get(key string) ([]byte, error) {
|
||||
switch {
|
||||
case c.file != nil:
|
||||
return c.file.Get([]byte(key), nil)
|
||||
case c.mem != nil:
|
||||
value, ok := c.mem[key]
|
||||
if !ok {
|
||||
return nil, notfound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
panic("jmap cache with no backend")
|
||||
}
|
||||
|
||||
func (c *JMAPCache) put(key string, value []byte) error {
|
||||
switch {
|
||||
case c.file != nil:
|
||||
return c.file.Put([]byte(key), value, nil)
|
||||
case c.mem != nil:
|
||||
c.mem[key] = value
|
||||
return nil
|
||||
}
|
||||
panic("jmap cache with no backend")
|
||||
}
|
||||
|
||||
func (c *JMAPCache) delete(key string) error {
|
||||
switch {
|
||||
case c.file != nil:
|
||||
return c.file.Delete([]byte(key), nil)
|
||||
case c.mem != nil:
|
||||
delete(c.mem, key)
|
||||
return nil
|
||||
}
|
||||
panic("jmap cache with no backend")
|
||||
}
|
||||
35
worker/jmap/cache/email.go
vendored
Normal file
35
worker/jmap/cache/email.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
)
|
||||
|
||||
func (c *JMAPCache) GetEmail(id jmap.ID) (*email.Email, error) {
|
||||
buf, err := c.get(emailey(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e := new(email.Email)
|
||||
err = unmarshal(buf, e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutEmail(id jmap.ID, e *email.Email) error {
|
||||
buf, err := marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(emailey(id), buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteEmail(id jmap.ID) error {
|
||||
return c.delete(emailey(id))
|
||||
}
|
||||
|
||||
func emailey(id jmap.ID) string {
|
||||
return "email/" + string(id)
|
||||
}
|
||||
61
worker/jmap/cache/folder_contents.go
vendored
Normal file
61
worker/jmap/cache/folder_contents.go
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
)
|
||||
|
||||
type FolderContents struct {
|
||||
MailboxID jmap.ID
|
||||
QueryState string
|
||||
Filter *email.FilterCondition
|
||||
Sort []*email.SortComparator
|
||||
MessageIDs []jmap.ID
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) {
|
||||
buf, err := c.get(folderContentsKey(mailboxId))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := new(FolderContents)
|
||||
err = unmarshal(buf, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutFolderContents(mailboxId jmap.ID, m *FolderContents) error {
|
||||
buf, err := marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(folderContentsKey(mailboxId), buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteFolderContents(mailboxId jmap.ID) error {
|
||||
return c.delete(folderContentsKey(mailboxId))
|
||||
}
|
||||
|
||||
func folderContentsKey(mailboxId jmap.ID) string {
|
||||
return "foldercontents/" + string(mailboxId)
|
||||
}
|
||||
|
||||
func (f *FolderContents) NeedsRefresh(
|
||||
filter *email.FilterCondition, sort []*email.SortComparator,
|
||||
) bool {
|
||||
if f.QueryState == "" || f.Filter == nil || len(f.Sort) != len(sort) {
|
||||
return true
|
||||
}
|
||||
|
||||
for i := 0; i < len(sort) && i < len(f.Sort); i++ {
|
||||
if !reflect.DeepEqual(sort[i], f.Sort[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return !reflect.DeepEqual(filter, f.Filter)
|
||||
}
|
||||
35
worker/jmap/cache/gob.go
vendored
Normal file
35
worker/jmap/cache/gob.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
type jmapObject interface {
|
||||
*jmap.Session |
|
||||
*email.Email |
|
||||
*email.QueryResponse |
|
||||
*mailbox.Mailbox |
|
||||
*FolderContents |
|
||||
*IDList
|
||||
}
|
||||
|
||||
func marshal[T jmapObject](obj T) ([]byte, error) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
encoder := gob.NewEncoder(buf)
|
||||
err := encoder.Encode(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func unmarshal[T jmapObject](data []byte, obj T) error {
|
||||
buf := bytes.NewBuffer(data)
|
||||
decoder := gob.NewDecoder(buf)
|
||||
return decoder.Decode(obj)
|
||||
}
|
||||
35
worker/jmap/cache/mailbox.go
vendored
Normal file
35
worker/jmap/cache/mailbox.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
func (c *JMAPCache) GetMailbox(id jmap.ID) (*mailbox.Mailbox, error) {
|
||||
buf, err := c.get(mailboxKey(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := new(mailbox.Mailbox)
|
||||
err = unmarshal(buf, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutMailbox(id jmap.ID, m *mailbox.Mailbox) error {
|
||||
buf, err := marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(mailboxKey(id), buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteMailbox(id jmap.ID) error {
|
||||
return c.delete(mailboxKey(id))
|
||||
}
|
||||
|
||||
func mailboxKey(id jmap.ID) string {
|
||||
return "mailbox/" + string(id)
|
||||
}
|
||||
32
worker/jmap/cache/mailbox_list.go
vendored
Normal file
32
worker/jmap/cache/mailbox_list.go
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
)
|
||||
|
||||
type IDList struct {
|
||||
IDs []jmap.ID
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetMailboxList() ([]jmap.ID, error) {
|
||||
buf, err := c.get(mailboxListKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list IDList
|
||||
err = unmarshal(buf, &list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list.IDs, nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutMailboxList(list []jmap.ID) error {
|
||||
buf, err := marshal(&IDList{IDs: list})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(mailboxListKey, buf)
|
||||
}
|
||||
|
||||
const mailboxListKey = "mailbox/list"
|
||||
32
worker/jmap/cache/session.go
vendored
Normal file
32
worker/jmap/cache/session.go
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
)
|
||||
|
||||
func (c *JMAPCache) GetSession() (*jmap.Session, error) {
|
||||
buf, err := c.get(sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := new(jmap.Session)
|
||||
err = unmarshal(buf, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutSession(s *jmap.Session) error {
|
||||
buf, err := marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(sessionKey, buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteSession() error {
|
||||
return c.delete(sessionKey)
|
||||
}
|
||||
|
||||
const sessionKey = "session"
|
||||
30
worker/jmap/cache/state.go
vendored
Normal file
30
worker/jmap/cache/state.go
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
package cache
|
||||
|
||||
func (c *JMAPCache) GetMailboxState() (string, error) {
|
||||
buf, err := c.get(mailboxStateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutMailboxState(state string) error {
|
||||
return c.put(mailboxStateKey, []byte(state))
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetEmailState() (string, error) {
|
||||
buf, err := c.get(emailStateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutEmailState(state string) error {
|
||||
return c.put(emailStateKey, []byte(state))
|
||||
}
|
||||
|
||||
const (
|
||||
mailboxStateKey = "state/mailbox"
|
||||
emailStateKey = "state/email"
|
||||
)
|
||||
70
worker/jmap/configure.go
Normal file
70
worker/jmap/configure.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/jmap/cache"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) handleConfigure(msg *types.Configure) error {
|
||||
u, err := url.Parse(msg.Config.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.HasSuffix(u.Scheme, "+oauthbearer") {
|
||||
w.config.oauth = true
|
||||
} else {
|
||||
if u.User == nil {
|
||||
return fmt.Errorf("user:password not specified")
|
||||
} else if u.User.Username() == "" {
|
||||
return fmt.Errorf("username not specified")
|
||||
} else if _, ok := u.User.Password(); !ok {
|
||||
return fmt.Errorf("password not specified")
|
||||
}
|
||||
}
|
||||
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
w.config.user = u.User
|
||||
u.User = nil
|
||||
u.Scheme = "https"
|
||||
|
||||
w.config.endpoint = u.String()
|
||||
w.config.account = msg.Config
|
||||
w.config.cacheState = parseBool(msg.Config.Params["cache-state"])
|
||||
w.config.cacheBlobs = parseBool(msg.Config.Params["cache-blobs"])
|
||||
w.config.useLabels = parseBool(msg.Config.Params["use-labels"])
|
||||
w.config.allMail = msg.Config.Params["all-mail"]
|
||||
if w.config.allMail == "" {
|
||||
w.config.allMail = "All mail"
|
||||
}
|
||||
if ping, ok := msg.Config.Params["server-ping"]; ok {
|
||||
dur, err := time.ParseDuration(ping)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server-ping: %w", err)
|
||||
}
|
||||
w.config.serverPing = dur
|
||||
}
|
||||
|
||||
c, err := cache.NewJMAPCache(
|
||||
w.config.cacheState, w.config.cacheBlobs, msg.Config.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.cache = c
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(val string) bool {
|
||||
switch strings.ToLower(val) {
|
||||
case "1", "t", "true", "yes", "y", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
133
worker/jmap/connect.go
Normal file
133
worker/jmap/connect.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/identity"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) handleConnect(msg *types.Connect) error {
|
||||
client := &jmap.Client{SessionEndpoint: w.config.endpoint}
|
||||
|
||||
if w.config.oauth {
|
||||
pass, _ := w.config.user.Password()
|
||||
client.WithAccessToken(pass)
|
||||
} else {
|
||||
user := w.config.user.Username()
|
||||
pass, _ := w.config.user.Password()
|
||||
client.WithBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
if session, err := w.cache.GetSession(); err != nil {
|
||||
if err := client.Authenticate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.cache.PutSession(client.Session); err != nil {
|
||||
w.w.Warnf("PutSession: %s", err)
|
||||
}
|
||||
} else {
|
||||
client.Session = session
|
||||
}
|
||||
|
||||
switch {
|
||||
case client == nil:
|
||||
fallthrough
|
||||
case client.Session == nil:
|
||||
fallthrough
|
||||
case client.Session.PrimaryAccounts == nil:
|
||||
break
|
||||
default:
|
||||
w.accountId = client.Session.PrimaryAccounts[mail.URI]
|
||||
}
|
||||
|
||||
w.client = client
|
||||
|
||||
return w.GetIdentities()
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) GetIdentities() error {
|
||||
u, err := url.Parse(w.config.account.Outgoing.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetIdentities: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(u.Scheme, "jmap") {
|
||||
// no need for identities
|
||||
return nil
|
||||
}
|
||||
|
||||
var req jmap.Request
|
||||
|
||||
req.Invoke(&identity.Get{Account: w.accountId})
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *identity.GetResponse:
|
||||
for _, ident := range r.List {
|
||||
w.identities[ident.Email] = ident
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var seqnum uint64
|
||||
|
||||
func (w *JMAPWorker) Do(req *jmap.Request) (*jmap.Response, error) {
|
||||
seq := atomic.AddUint64(&seqnum, 1)
|
||||
body, _ := json.Marshal(req.Calls)
|
||||
w.w.Debugf(">%d> POST %s", seq, body)
|
||||
resp, err := w.client.Do(req)
|
||||
if err == nil {
|
||||
w.w.Debugf("<%d< done", seq)
|
||||
} else {
|
||||
w.w.Debugf("<%d< %s", seq, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) Download(blobID jmap.ID) (io.ReadCloser, error) {
|
||||
seq := atomic.AddUint64(&seqnum, 1)
|
||||
replacer := strings.NewReplacer(
|
||||
"{accountId}", string(w.accountId),
|
||||
"{blobId}", string(blobID),
|
||||
"{type}", "application/octet-stream",
|
||||
"{name}", "filename",
|
||||
)
|
||||
url := replacer.Replace(w.client.Session.DownloadURL)
|
||||
w.w.Debugf(">%d> GET %s", seq, url)
|
||||
rd, err := w.client.Download(w.accountId, blobID)
|
||||
if err == nil {
|
||||
w.w.Debugf("<%d< 200 OK", seq)
|
||||
} else {
|
||||
w.w.Debugf("<%d< %s", seq, err)
|
||||
}
|
||||
return rd, err
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) Upload(reader io.Reader) (*jmap.UploadResponse, error) {
|
||||
seq := atomic.AddUint64(&seqnum, 1)
|
||||
url := strings.ReplaceAll(w.client.Session.UploadURL,
|
||||
"{accountId}", string(w.accountId))
|
||||
w.w.Debugf(">%d> POST %s", seq, url)
|
||||
resp, err := w.client.Upload(w.accountId, reader)
|
||||
if err == nil {
|
||||
w.w.Debugf("<%d< 200 OK", seq)
|
||||
} else {
|
||||
w.w.Debugf("<%d< %s", seq, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
360
worker/jmap/directories.go
Normal file
360
worker/jmap/directories.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/jmap/cache"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) handleListDirectories(msg *types.ListDirectories) error {
|
||||
var ids, missing []jmap.ID
|
||||
var labels []string
|
||||
var mboxes map[jmap.ID]*mailbox.Mailbox
|
||||
|
||||
mboxes = make(map[jmap.ID]*mailbox.Mailbox)
|
||||
|
||||
mboxIds, err := w.cache.GetMailboxList()
|
||||
if err == nil {
|
||||
for _, id := range mboxIds {
|
||||
mbox, err := w.cache.GetMailbox(id)
|
||||
if err != nil {
|
||||
w.w.Warnf("GetMailbox: %s", err)
|
||||
missing = append(missing, id)
|
||||
continue
|
||||
}
|
||||
mboxes[id] = mbox
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil || len(missing) > 0 {
|
||||
var req jmap.Request
|
||||
|
||||
req.Invoke(&mailbox.Get{Account: w.accountId})
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mboxes = make(map[jmap.ID]*mailbox.Mailbox)
|
||||
ids = make([]jmap.ID, 0)
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *mailbox.GetResponse:
|
||||
for _, mbox := range r.List {
|
||||
mboxes[mbox.ID] = mbox
|
||||
ids = append(ids, mbox.ID)
|
||||
err = w.cache.PutMailbox(mbox.ID, mbox)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutMailbox: %s", err)
|
||||
}
|
||||
}
|
||||
err = w.cache.PutMailboxList(ids)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutMailboxList: %s", err)
|
||||
}
|
||||
err = w.cache.PutMailboxState(r.State)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutMailboxState: %s", err)
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(mboxes) == 0 {
|
||||
return errors.New("no mailboxes")
|
||||
}
|
||||
|
||||
for _, mbox := range mboxes {
|
||||
dir := w.MailboxPath(mbox)
|
||||
w.addMbox(mbox, dir)
|
||||
labels = append(labels, dir)
|
||||
}
|
||||
if w.config.useLabels {
|
||||
sort.Strings(labels)
|
||||
w.w.PostMessage(&types.LabelList{Labels: labels}, nil)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
mbox := mboxes[id]
|
||||
if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
|
||||
// replace archive with virtual all-mail folder
|
||||
mbox = &mailbox.Mailbox{
|
||||
Name: w.config.allMail,
|
||||
Role: mailbox.RoleAll,
|
||||
}
|
||||
w.addMbox(mbox, mbox.Name)
|
||||
}
|
||||
w.w.PostMessage(&types.Directory{
|
||||
Message: types.RespondTo(msg),
|
||||
Dir: &models.Directory{
|
||||
Name: w.mbox2dir[mbox.ID],
|
||||
Exists: int(mbox.TotalEmails),
|
||||
Unseen: int(mbox.UnreadEmails),
|
||||
Role: jmapRole2aerc[mbox.Role],
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
go w.monitorChanges()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleOpenDirectory(msg *types.OpenDirectory) error {
|
||||
id, ok := w.dir2mbox[msg.Directory]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown directory: %s", msg.Directory)
|
||||
}
|
||||
w.selectedMbox = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryContents) error {
|
||||
contents, err := w.cache.GetFolderContents(w.selectedMbox)
|
||||
if err != nil {
|
||||
contents = &cache.FolderContents{
|
||||
MailboxID: w.selectedMbox,
|
||||
Filter: &email.FilterCondition{},
|
||||
}
|
||||
}
|
||||
|
||||
filter, err := parseSearch(msg.FilterCriteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter.InMailbox = w.selectedMbox
|
||||
|
||||
sort := translateSort(msg.SortCriteria)
|
||||
|
||||
if contents.NeedsRefresh(filter, sort) {
|
||||
var req jmap.Request
|
||||
|
||||
req.Invoke(&email.Query{
|
||||
Account: w.accountId,
|
||||
Filter: filter,
|
||||
Sort: sort,
|
||||
})
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var canCalculateChanges bool
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.QueryResponse:
|
||||
contents.Sort = sort
|
||||
contents.Filter = filter
|
||||
contents.QueryState = r.QueryState
|
||||
contents.MessageIDs = r.IDs
|
||||
canCalculateChanges = r.CanCalculateChanges
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
if canCalculateChanges {
|
||||
err = w.cache.PutFolderContents(w.selectedMbox, contents)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutFolderContents: %s", err)
|
||||
}
|
||||
} else {
|
||||
w.w.Debugf("%q: server cannot calculate changes, flushing cache",
|
||||
w.mbox2dir[w.selectedMbox])
|
||||
err = w.cache.DeleteFolderContents(w.selectedMbox)
|
||||
if err != nil {
|
||||
w.w.Warnf("DeleteFolderContents: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uids := make([]uint32, 0, len(contents.MessageIDs))
|
||||
for _, id := range contents.MessageIDs {
|
||||
uids = append(uids, w.uidStore.GetOrInsert(string(id)))
|
||||
}
|
||||
w.w.PostMessage(&types.DirectoryContents{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) error {
|
||||
var req jmap.Request
|
||||
|
||||
filter, err := parseSearch(msg.Argv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.selectedMbox == "" {
|
||||
// all mail virtual folder: display all but trash and spam
|
||||
var mboxes []jmap.ID
|
||||
if id, ok := w.roles[mailbox.RoleJunk]; ok {
|
||||
mboxes = append(mboxes, id)
|
||||
}
|
||||
if id, ok := w.roles[mailbox.RoleTrash]; ok {
|
||||
mboxes = append(mboxes, id)
|
||||
}
|
||||
filter.InMailboxOtherThan = mboxes
|
||||
} else {
|
||||
filter.InMailbox = w.selectedMbox
|
||||
}
|
||||
|
||||
req.Invoke(&email.Query{
|
||||
Account: w.accountId,
|
||||
Filter: filter,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.QueryResponse:
|
||||
var uids []uint32
|
||||
for _, id := range r.IDs {
|
||||
uids = append(uids, w.uidStore.GetOrInsert(string(id)))
|
||||
}
|
||||
w.w.PostMessage(&types.SearchResults{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleCreateDirectory(msg *types.CreateDirectory) error {
|
||||
var req jmap.Request
|
||||
var parentId, id jmap.ID
|
||||
|
||||
if _, ok := w.dir2mbox[msg.Directory]; ok {
|
||||
// directory already exists
|
||||
return nil
|
||||
}
|
||||
if parent := path.Dir(msg.Directory); parent != "" && parent != "." {
|
||||
var ok bool
|
||||
if parentId, ok = w.dir2mbox[parent]; !ok {
|
||||
return fmt.Errorf(
|
||||
"parent mailbox %q does not exist", parent)
|
||||
}
|
||||
}
|
||||
name := path.Base(msg.Directory)
|
||||
id = jmap.ID(msg.Directory)
|
||||
|
||||
req.Invoke(&mailbox.Set{
|
||||
Account: w.accountId,
|
||||
Create: map[jmap.ID]*mailbox.Mailbox{
|
||||
id: {
|
||||
ParentID: parentId,
|
||||
Name: name,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *mailbox.SetResponse:
|
||||
if err := r.NotCreated[id]; err != nil {
|
||||
e := wrapSetError(err)
|
||||
if msg.Quiet {
|
||||
w.w.Warnf("mailbox creation failed: %s", e)
|
||||
} else {
|
||||
return e
|
||||
}
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleRemoveDirectory(msg *types.RemoveDirectory) error {
|
||||
var req jmap.Request
|
||||
|
||||
id, ok := w.dir2mbox[msg.Directory]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown mailbox: %s", msg.Directory)
|
||||
}
|
||||
|
||||
req.Invoke(&mailbox.Set{
|
||||
Account: w.accountId,
|
||||
Destroy: []jmap.ID{id},
|
||||
OnDestroyRemoveEmails: msg.Quiet,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *mailbox.SetResponse:
|
||||
if err := r.NotDestroyed[id]; err != nil {
|
||||
return wrapSetError(err)
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func translateSort(criteria []*types.SortCriterion) []*email.SortComparator {
|
||||
sort := make([]*email.SortComparator, 0, len(criteria))
|
||||
if len(criteria) == 0 {
|
||||
criteria = []*types.SortCriterion{
|
||||
{Field: types.SortArrival, Reverse: true},
|
||||
}
|
||||
}
|
||||
for _, s := range criteria {
|
||||
var cmp email.SortComparator
|
||||
switch s.Field {
|
||||
case types.SortArrival:
|
||||
cmp.Property = "receivedAt"
|
||||
case types.SortCc:
|
||||
cmp.Property = "cc"
|
||||
case types.SortDate:
|
||||
cmp.Property = "receivedAt"
|
||||
case types.SortFrom:
|
||||
cmp.Property = "from"
|
||||
case types.SortRead:
|
||||
cmp.Keyword = "$seen"
|
||||
case types.SortSize:
|
||||
cmp.Property = "size"
|
||||
case types.SortSubject:
|
||||
cmp.Property = "subject"
|
||||
case types.SortTo:
|
||||
cmp.Property = "to"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
cmp.IsAscending = !s.Reverse
|
||||
sort = append(sort, &cmp)
|
||||
}
|
||||
|
||||
return sort
|
||||
}
|
||||
196
worker/jmap/fetch.go
Normal file
196
worker/jmap/fetch.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"github.com/emersion/go-message/charset"
|
||||
)
|
||||
|
||||
var headersProperties = []string{
|
||||
"id",
|
||||
"blobId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"headers",
|
||||
"messageId",
|
||||
"inReplyTo",
|
||||
"references",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"replyTo",
|
||||
"subject",
|
||||
"bodyStructure",
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleFetchMessageHeaders(msg *types.FetchMessageHeaders) error {
|
||||
var req jmap.Request
|
||||
|
||||
ids := make([]jmap.ID, 0, len(msg.Uids))
|
||||
for _, uid := range msg.Uids {
|
||||
id, ok := w.uidStore.GetKey(uid)
|
||||
if !ok {
|
||||
return fmt.Errorf("bug: no jmap id for message uid: %v", uid)
|
||||
}
|
||||
jid := jmap.ID(id)
|
||||
m, err := w.cache.GetEmail(jid)
|
||||
if err == nil {
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: w.translateMsgInfo(m),
|
||||
}, nil)
|
||||
continue
|
||||
}
|
||||
ids = append(ids, jid)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
req.Invoke(&email.Get{
|
||||
Account: w.accountId,
|
||||
IDs: ids,
|
||||
Properties: headersProperties,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.GetResponse:
|
||||
for _, m := range r.List {
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: w.translateMsgInfo(m),
|
||||
}, nil)
|
||||
if err := w.cache.PutEmail(m.ID, m); err != nil {
|
||||
w.w.Warnf("PutEmail: %s", err)
|
||||
}
|
||||
}
|
||||
if err = w.cache.PutEmailState(r.State); err != nil {
|
||||
w.w.Warnf("PutEmailState: %s", err)
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleFetchMessageBodyPart(msg *types.FetchMessageBodyPart) error {
|
||||
id, ok := w.uidStore.GetKey(msg.Uid)
|
||||
if !ok {
|
||||
return fmt.Errorf("bug: unknown message uid %d", msg.Uid)
|
||||
}
|
||||
mail, err := w.cache.GetEmail(jmap.ID(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bug: unknown message id %s: %w", id, err)
|
||||
}
|
||||
|
||||
part := mail.BodyStructure
|
||||
for i, index := range msg.Part {
|
||||
index -= 1 // convert to zero based offset
|
||||
if index < len(part.SubParts) {
|
||||
part = part.SubParts[index]
|
||||
} else {
|
||||
return fmt.Errorf(
|
||||
"bug: invalid part index[%d]: %v", i, msg.Part)
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := w.cache.GetBlob(part.BlobID)
|
||||
if err != nil {
|
||||
rd, err := w.Download(part.BlobID)
|
||||
if err != nil {
|
||||
return w.wrapDownloadError("part", part.BlobID, err)
|
||||
}
|
||||
buf, err = io.ReadAll(rd)
|
||||
rd.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = w.cache.PutBlob(part.BlobID, buf); err != nil {
|
||||
w.w.Warnf("PutBlob: %s", err)
|
||||
}
|
||||
}
|
||||
var reader io.Reader = bytes.NewReader(buf)
|
||||
if strings.HasPrefix(part.Type, "text/") && part.Charset != "" {
|
||||
r, err := charset.Reader(part.Charset, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("charset.Reader: %w", err)
|
||||
}
|
||||
reader = r
|
||||
}
|
||||
w.w.PostMessage(&types.MessageBodyPart{
|
||||
Message: types.RespondTo(msg),
|
||||
Part: &models.MessageBodyPart{
|
||||
Reader: reader,
|
||||
Uid: msg.Uid,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleFetchFullMessages(msg *types.FetchFullMessages) error {
|
||||
for _, uid := range msg.Uids {
|
||||
id, ok := w.uidStore.GetKey(uid)
|
||||
if !ok {
|
||||
return fmt.Errorf("bug: unknown message uid %d", uid)
|
||||
}
|
||||
mail, err := w.cache.GetEmail(jmap.ID(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bug: unknown message id %s: %w", id, err)
|
||||
}
|
||||
buf, err := w.cache.GetBlob(mail.BlobID)
|
||||
if err != nil {
|
||||
rd, err := w.Download(mail.BlobID)
|
||||
if err != nil {
|
||||
return w.wrapDownloadError("full", mail.BlobID, err)
|
||||
}
|
||||
buf, err = io.ReadAll(rd)
|
||||
rd.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = w.cache.PutBlob(mail.BlobID, buf); err != nil {
|
||||
w.w.Warnf("PutBlob: %s", err)
|
||||
}
|
||||
}
|
||||
w.w.PostMessage(&types.FullMessage{
|
||||
Message: types.RespondTo(msg),
|
||||
Content: &models.FullMessage{
|
||||
Reader: bytes.NewReader(buf),
|
||||
Uid: uid,
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) wrapDownloadError(prefix string, blobId jmap.ID, err error) error {
|
||||
urlRepl := strings.NewReplacer(
|
||||
"{accountId}", string(w.accountId),
|
||||
"{blobId}", string(blobId),
|
||||
"{type}", "application/octet-stream",
|
||||
"{name}", "filename",
|
||||
)
|
||||
url := urlRepl.Replace(w.client.Session.DownloadURL)
|
||||
return fmt.Errorf("%s: %q %w", prefix, url, err)
|
||||
}
|
||||
174
worker/jmap/jmap.go
Normal file
174
worker/jmap/jmap.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
msgmail "github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) translateMsgInfo(m *email.Email) *models.MessageInfo {
|
||||
env := &models.Envelope{
|
||||
Date: *m.ReceivedAt,
|
||||
Subject: m.Subject,
|
||||
From: translateAddrList(m.From),
|
||||
ReplyTo: translateAddrList(m.ReplyTo),
|
||||
To: translateAddrList(m.To),
|
||||
Cc: translateAddrList(m.CC),
|
||||
Bcc: translateAddrList(m.BCC),
|
||||
MessageId: firstString(m.MessageID),
|
||||
InReplyTo: firstString(m.InReplyTo),
|
||||
}
|
||||
labels := make([]string, 0, len(m.MailboxIDs))
|
||||
for id := range m.MailboxIDs {
|
||||
if dir, ok := w.mbox2dir[id]; ok {
|
||||
labels = append(labels, dir)
|
||||
}
|
||||
}
|
||||
sort.Strings(labels)
|
||||
|
||||
return &models.MessageInfo{
|
||||
Envelope: env,
|
||||
Flags: keywordsToFlags(m.Keywords),
|
||||
Uid: w.uidStore.GetOrInsert(string(m.ID)),
|
||||
BodyStructure: translateBodyStructure(m.BodyStructure),
|
||||
RFC822Headers: translateJMAPHeader(m.Headers),
|
||||
Refs: m.References,
|
||||
Labels: labels,
|
||||
Size: uint32(m.Size),
|
||||
InternalDate: *m.ReceivedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func translateJMAPHeader(headers []*email.Header) *msgmail.Header {
|
||||
hdr := new(msgmail.Header)
|
||||
for _, h := range headers {
|
||||
raw := fmt.Sprintf("%s:%s\r\n", h.Name, h.Value)
|
||||
hdr.AddRaw([]byte(raw))
|
||||
}
|
||||
return hdr
|
||||
}
|
||||
|
||||
func flagsToKeywords(flags models.Flags) map[string]bool {
|
||||
kw := make(map[string]bool)
|
||||
if flags.Has(models.SeenFlag) {
|
||||
kw["$seen"] = true
|
||||
}
|
||||
if flags.Has(models.AnsweredFlag) {
|
||||
kw["$answered"] = true
|
||||
}
|
||||
if flags.Has(models.FlaggedFlag) {
|
||||
kw["$flagged"] = true
|
||||
}
|
||||
return kw
|
||||
}
|
||||
|
||||
func keywordsToFlags(kw map[string]bool) models.Flags {
|
||||
var f models.Flags
|
||||
for k, v := range kw {
|
||||
if v {
|
||||
switch k {
|
||||
case "$seen":
|
||||
f |= models.SeenFlag
|
||||
case "$answered":
|
||||
f |= models.AnsweredFlag
|
||||
case "$flagged":
|
||||
f |= models.FlaggedFlag
|
||||
}
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) MailboxPath(mbox *mailbox.Mailbox) string {
|
||||
if mbox == nil {
|
||||
return ""
|
||||
}
|
||||
if mbox.ParentID == "" {
|
||||
return mbox.Name
|
||||
}
|
||||
parent, err := w.cache.GetMailbox(mbox.ParentID)
|
||||
if err != nil {
|
||||
w.w.Warnf("MailboxPath/GetMailbox: %s", err)
|
||||
return mbox.Name
|
||||
}
|
||||
return w.MailboxPath(parent) + "/" + mbox.Name
|
||||
}
|
||||
|
||||
var jmapRole2aerc = map[mailbox.Role]models.Role{
|
||||
mailbox.RoleAll: models.AllRole,
|
||||
mailbox.RoleArchive: models.ArchiveRole,
|
||||
mailbox.RoleDrafts: models.DraftsRole,
|
||||
mailbox.RoleInbox: models.InboxRole,
|
||||
mailbox.RoleJunk: models.JunkRole,
|
||||
mailbox.RoleSent: models.SentRole,
|
||||
mailbox.RoleTrash: models.TrashRole,
|
||||
}
|
||||
|
||||
func firstString(s []string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s[0]
|
||||
}
|
||||
|
||||
func translateAddrList(addrs []*mail.Address) []*msgmail.Address {
|
||||
res := make([]*msgmail.Address, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
res = append(res, &msgmail.Address{Name: a.Name, Address: a.Email})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func translateBodyStructure(part *email.BodyPart) *models.BodyStructure {
|
||||
bs := &models.BodyStructure{
|
||||
Description: part.Name,
|
||||
Encoding: part.Charset,
|
||||
Params: map[string]string{
|
||||
"name": part.Name,
|
||||
"charset": part.Charset,
|
||||
},
|
||||
Disposition: part.Disposition,
|
||||
DispositionParams: map[string]string{
|
||||
"filename": part.Name,
|
||||
},
|
||||
}
|
||||
bs.MIMEType, bs.MIMESubType, _ = strings.Cut(part.Type, "/")
|
||||
for _, sub := range part.SubParts {
|
||||
bs.Parts = append(bs.Parts, translateBodyStructure(sub))
|
||||
}
|
||||
return bs
|
||||
}
|
||||
|
||||
func wrapSetError(err *jmap.SetError) error {
|
||||
var s string
|
||||
if err.Description != nil {
|
||||
s = *err.Description
|
||||
} else {
|
||||
s = err.Type
|
||||
if err.Properties != nil {
|
||||
s += fmt.Sprintf(" %v", *err.Properties)
|
||||
}
|
||||
if s == "invalidProperties: [mailboxIds]" {
|
||||
s = "a message must belong to one or more mailboxes"
|
||||
}
|
||||
}
|
||||
return errors.New(s)
|
||||
}
|
||||
|
||||
func wrapMethodError(err *jmap.MethodError) error {
|
||||
var s string
|
||||
if err.Description != nil {
|
||||
s = *err.Description
|
||||
} else {
|
||||
s = err.Type
|
||||
}
|
||||
return errors.New(s)
|
||||
}
|
||||
333
worker/jmap/push.go
Normal file
333
worker/jmap/push.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/jmap/cache"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/core/push"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) monitorChanges() {
|
||||
events := push.EventSource{
|
||||
Client: w.client,
|
||||
Handler: w.handleChange,
|
||||
Ping: uint(w.config.serverPing.Seconds()),
|
||||
}
|
||||
|
||||
w.stop = make(chan struct{})
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
<-w.stop
|
||||
w.w.Errorf("listen stopping")
|
||||
w.stop = nil
|
||||
events.Close()
|
||||
}()
|
||||
|
||||
for w.stop != nil {
|
||||
w.w.Debugf("listening for changes")
|
||||
err := events.Listen()
|
||||
if err != nil {
|
||||
w.w.PostMessage(&types.Error{
|
||||
Error: fmt.Errorf("jmap listen: %w", err),
|
||||
}, nil)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleChange(s *jmap.StateChange) {
|
||||
changed, ok := s.Changed[w.accountId]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.w.Debugf("state change %#v", changed)
|
||||
w.changes <- changed
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) refresh(newState jmap.TypeState) error {
|
||||
var req jmap.Request
|
||||
|
||||
mboxState, err := w.cache.GetMailboxState()
|
||||
if err != nil {
|
||||
w.w.Debugf("GetMailboxState: %s", err)
|
||||
}
|
||||
if mboxState != "" && newState["Mailbox"] != mboxState {
|
||||
callID := req.Invoke(&mailbox.Changes{
|
||||
Account: w.accountId,
|
||||
SinceState: mboxState,
|
||||
})
|
||||
req.Invoke(&mailbox.Get{
|
||||
Account: w.accountId,
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Mailbox/changes",
|
||||
Path: "/created",
|
||||
},
|
||||
})
|
||||
req.Invoke(&mailbox.Get{
|
||||
Account: w.accountId,
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Mailbox/changes",
|
||||
Path: "/updated",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
emailState, err := w.cache.GetEmailState()
|
||||
if err != nil {
|
||||
w.w.Debugf("GetEmailState: %s", err)
|
||||
}
|
||||
queryChangesCalls := make(map[string]jmap.ID)
|
||||
folderContents := make(map[jmap.ID]*cache.FolderContents)
|
||||
ids, _ := w.cache.GetMailboxList()
|
||||
mboxes := make(map[jmap.ID]*mailbox.Mailbox)
|
||||
for _, id := range ids {
|
||||
mbox, err := w.cache.GetMailbox(id)
|
||||
if err != nil {
|
||||
w.w.Warnf("GetMailbox: %s", err)
|
||||
continue
|
||||
}
|
||||
if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
|
||||
mboxes[""] = &mailbox.Mailbox{
|
||||
Name: w.config.allMail,
|
||||
Role: mailbox.RoleAll,
|
||||
}
|
||||
} else {
|
||||
mboxes[id] = mbox
|
||||
}
|
||||
}
|
||||
if emailState != "" && newState["Email"] != emailState {
|
||||
callID := req.Invoke(&email.Changes{
|
||||
Account: w.accountId,
|
||||
SinceState: emailState,
|
||||
})
|
||||
req.Invoke(&email.Get{
|
||||
Account: w.accountId,
|
||||
Properties: headersProperties,
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Email/changes",
|
||||
Path: "/updated",
|
||||
},
|
||||
})
|
||||
|
||||
for id := range mboxes {
|
||||
contents, err := w.cache.GetFolderContents(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
callID = req.Invoke(&email.QueryChanges{
|
||||
Account: w.accountId,
|
||||
Filter: contents.Filter,
|
||||
Sort: contents.Sort,
|
||||
SinceQueryState: contents.QueryState,
|
||||
})
|
||||
queryChangesCalls[callID] = id
|
||||
folderContents[id] = contents
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.Calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var changedMboxIds []jmap.ID
|
||||
var labelsChanged bool
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *mailbox.ChangesResponse:
|
||||
for _, id := range r.Destroyed {
|
||||
dir, ok := w.mbox2dir[id]
|
||||
if ok {
|
||||
w.w.PostMessage(&types.RemoveDirectory{
|
||||
Directory: dir,
|
||||
}, nil)
|
||||
}
|
||||
w.deleteMbox(id)
|
||||
err = w.cache.DeleteMailbox(id)
|
||||
if err != nil {
|
||||
w.w.Warnf("DeleteMailbox: %s", err)
|
||||
}
|
||||
labelsChanged = true
|
||||
}
|
||||
err = w.cache.PutMailboxState(r.NewState)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutMailboxState: %s", err)
|
||||
}
|
||||
|
||||
case *mailbox.GetResponse:
|
||||
for _, mbox := range r.List {
|
||||
changedMboxIds = append(changedMboxIds, mbox.ID)
|
||||
mboxes[mbox.ID] = mbox
|
||||
err = w.cache.PutMailbox(mbox.ID, mbox)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutMailbox: %s", err)
|
||||
}
|
||||
}
|
||||
err = w.cache.PutMailboxState(r.State)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutMailboxState: %s", err)
|
||||
}
|
||||
|
||||
case *email.QueryChangesResponse:
|
||||
mboxId := queryChangesCalls[inv.CallID]
|
||||
contents := folderContents[mboxId]
|
||||
|
||||
removed := make(map[jmap.ID]bool)
|
||||
for _, id := range r.Removed {
|
||||
removed[id] = true
|
||||
}
|
||||
added := make(map[int]jmap.ID)
|
||||
for _, add := range r.Added {
|
||||
added[int(add.Index)] = add.ID
|
||||
}
|
||||
w.w.Debugf("%q: %d added, %d removed",
|
||||
w.mbox2dir[mboxId], len(added), len(removed))
|
||||
n := len(contents.MessageIDs) - len(removed) + len(added)
|
||||
if n < 0 {
|
||||
w.w.Errorf("bug: invalid folder contents state")
|
||||
err = w.cache.DeleteFolderContents(mboxId)
|
||||
if err != nil {
|
||||
w.w.Warnf("DeleteFolderContents: %s", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
ids = make([]jmap.ID, 0, n)
|
||||
i := 0
|
||||
for _, id := range contents.MessageIDs {
|
||||
if removed[id] {
|
||||
continue
|
||||
}
|
||||
if addedId, ok := added[i]; ok {
|
||||
ids = append(ids, addedId)
|
||||
delete(added, i)
|
||||
i += 1
|
||||
}
|
||||
ids = append(ids, id)
|
||||
i += 1
|
||||
}
|
||||
for _, id := range added {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
contents.MessageIDs = ids
|
||||
contents.QueryState = r.NewQueryState
|
||||
|
||||
err = w.cache.PutFolderContents(mboxId, contents)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutFolderContents: %s", err)
|
||||
}
|
||||
|
||||
if w.selectedMbox == mboxId {
|
||||
uids := make([]uint32, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
uid := w.uidStore.GetOrInsert(string(id))
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
w.w.PostMessage(&types.DirectoryContents{
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
case *email.GetResponse:
|
||||
selectedIds := make(map[jmap.ID]bool)
|
||||
contents, ok := folderContents[w.selectedMbox]
|
||||
if ok {
|
||||
for _, id := range contents.MessageIDs {
|
||||
selectedIds[id] = true
|
||||
}
|
||||
}
|
||||
for _, m := range r.List {
|
||||
err = w.cache.PutEmail(m.ID, m)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutEmail: %s", err)
|
||||
}
|
||||
if selectedIds[m.ID] {
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Info: w.translateMsgInfo(m),
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
err = w.cache.PutEmailState(r.State)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutEmailState: %s", err)
|
||||
}
|
||||
|
||||
case *jmap.MethodError:
|
||||
w.w.Errorf("%s: %s", wrapMethodError(r))
|
||||
if inv.Name == "Email/queryChanges" {
|
||||
id := queryChangesCalls[inv.CallID]
|
||||
w.w.Infof("flushing %q contents from cache",
|
||||
w.mbox2dir[id])
|
||||
err := w.cache.DeleteFolderContents(id)
|
||||
if err != nil {
|
||||
w.w.Warnf("DeleteFolderContents: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range changedMboxIds {
|
||||
mbox := mboxes[id]
|
||||
newDir := w.MailboxPath(mbox)
|
||||
dir, ok := w.mbox2dir[id]
|
||||
if ok {
|
||||
// updated
|
||||
if newDir == dir {
|
||||
w.deleteMbox(id)
|
||||
w.addMbox(mbox, dir)
|
||||
w.w.PostMessage(&types.DirectoryInfo{
|
||||
Info: &models.DirectoryInfo{
|
||||
Name: dir,
|
||||
Exists: int(mbox.TotalEmails),
|
||||
Unseen: int(mbox.UnreadEmails),
|
||||
},
|
||||
}, nil)
|
||||
continue
|
||||
} else {
|
||||
// renamed mailbox
|
||||
w.deleteMbox(id)
|
||||
w.w.PostMessage(&types.RemoveDirectory{
|
||||
Directory: dir,
|
||||
}, nil)
|
||||
dir = newDir
|
||||
}
|
||||
}
|
||||
// new mailbox
|
||||
w.addMbox(mbox, dir)
|
||||
w.w.PostMessage(&types.Directory{
|
||||
Dir: &models.Directory{
|
||||
Name: dir,
|
||||
Exists: int(mbox.TotalEmails),
|
||||
Unseen: int(mbox.UnreadEmails),
|
||||
Role: jmapRole2aerc[mbox.Role],
|
||||
},
|
||||
}, nil)
|
||||
labelsChanged = true
|
||||
}
|
||||
|
||||
if w.config.useLabels && labelsChanged {
|
||||
labels := make([]string, 0, len(w.dir2mbox))
|
||||
for dir := range w.dir2mbox {
|
||||
labels = append(labels, dir)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
w.w.PostMessage(&types.LabelList{Labels: labels}, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
63
worker/jmap/search.go
Normal file
63
worker/jmap/search.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~sircmpwn/getopt"
|
||||
)
|
||||
|
||||
func parseSearch(args []string) (*email.FilterCondition, error) {
|
||||
f := new(email.FilterCondition)
|
||||
if len(args) == 0 {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
opts, optind, err := getopt.Getopts(args, "rubax:X:t:H:f:c:d:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := false
|
||||
text := false
|
||||
for _, opt := range opts {
|
||||
switch opt.Option {
|
||||
case 'r':
|
||||
f.HasKeyword = "$seen"
|
||||
case 'u':
|
||||
f.NotKeyword = "$seen"
|
||||
case 'f':
|
||||
f.From = opt.Value
|
||||
case 't':
|
||||
f.To = opt.Value
|
||||
case 'c':
|
||||
f.Cc = opt.Value
|
||||
case 'b':
|
||||
body = true
|
||||
case 'a':
|
||||
text = true
|
||||
case 'd':
|
||||
start, end, err := lib.ParseDateRange(opt.Value)
|
||||
if err != nil {
|
||||
log.Errorf("failed to parse start date: %v", err)
|
||||
continue
|
||||
}
|
||||
if !start.IsZero() {
|
||||
f.After = &start
|
||||
}
|
||||
if !end.IsZero() {
|
||||
f.Before = &end
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case text:
|
||||
f.Text = strings.Join(args[optind:], " ")
|
||||
case body:
|
||||
f.Body = strings.Join(args[optind:], " ")
|
||||
default:
|
||||
f.Subject = strings.Join(args[optind:], " ")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
144
worker/jmap/send.go
Normal file
144
worker/jmap/send.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/emailsubmission"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) handleStartSend(msg *types.StartSendingMessage) error {
|
||||
reader, writer := io.Pipe()
|
||||
send := &jmapSendWriter{writer: writer, done: make(chan error)}
|
||||
|
||||
w.w.PostMessage(&types.MessageWriter{
|
||||
Message: types.RespondTo(msg),
|
||||
Writer: send,
|
||||
}, nil)
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
defer close(send.done)
|
||||
|
||||
identity, err := w.getSenderIdentity(msg.Header)
|
||||
if err != nil {
|
||||
send.done <- err
|
||||
return
|
||||
}
|
||||
|
||||
blob, err := w.Upload(reader)
|
||||
if err != nil {
|
||||
send.done <- err
|
||||
return
|
||||
}
|
||||
|
||||
var req jmap.Request
|
||||
|
||||
// Import the blob into drafts
|
||||
req.Invoke(&email.Import{
|
||||
Account: w.accountId,
|
||||
Emails: map[string]*email.EmailImport{
|
||||
"aerc": {
|
||||
BlobID: blob.ID,
|
||||
MailboxIDs: map[jmap.ID]bool{
|
||||
w.roles[mailbox.RoleDrafts]: true,
|
||||
},
|
||||
Keywords: map[string]bool{
|
||||
"$draft": true,
|
||||
"$seen": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create the submission
|
||||
req.Invoke(&emailsubmission.Set{
|
||||
Account: w.accountId,
|
||||
Create: map[jmap.ID]*emailsubmission.EmailSubmission{
|
||||
"sub": {
|
||||
IdentityID: identity,
|
||||
EmailID: "#aerc",
|
||||
},
|
||||
},
|
||||
OnSuccessUpdateEmail: map[jmap.ID]jmap.Patch{
|
||||
"#sub": {
|
||||
"keywords/$draft": nil,
|
||||
w.rolePatch(mailbox.RoleSent): true,
|
||||
w.rolePatch(mailbox.RoleDrafts): nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
send.done <- err
|
||||
return
|
||||
}
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.ImportResponse:
|
||||
if err, ok := r.NotCreated["aerc"]; ok {
|
||||
send.done <- wrapSetError(err)
|
||||
return
|
||||
}
|
||||
case *emailsubmission.SetResponse:
|
||||
if err, ok := r.NotCreated["sub"]; ok {
|
||||
send.done <- wrapSetError(err)
|
||||
return
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
send.done <- wrapMethodError(r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type jmapSendWriter struct {
|
||||
writer *io.PipeWriter
|
||||
done chan error
|
||||
}
|
||||
|
||||
func (w *jmapSendWriter) Write(data []byte) (int, error) {
|
||||
return w.writer.Write(data)
|
||||
}
|
||||
|
||||
func (w *jmapSendWriter) Close() error {
|
||||
writeErr := w.writer.Close()
|
||||
sendErr := <-w.done
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return sendErr
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) getSenderIdentity(header *mail.Header) (jmap.ID, error) {
|
||||
from, err := header.AddressList("from")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("msg.Header.AddressList: %w", err)
|
||||
}
|
||||
if len(from) != 1 {
|
||||
return "", fmt.Errorf("no from header in message")
|
||||
}
|
||||
name, domain, _ := strings.Cut(from[0].Address, "@")
|
||||
for _, ident := range w.identities {
|
||||
n, d, _ := strings.Cut(ident.Email, "@")
|
||||
switch {
|
||||
case n == name && d == domain:
|
||||
fallthrough
|
||||
case n == "*" && d == domain:
|
||||
return ident.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no identity found for address: %s@%s", name, domain)
|
||||
}
|
||||
241
worker/jmap/set.go
Normal file
241
worker/jmap/set.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) updateFlags(uids []uint32, flags models.Flags, enable bool) error {
|
||||
var req jmap.Request
|
||||
patches := make(map[jmap.ID]jmap.Patch)
|
||||
|
||||
for _, uid := range uids {
|
||||
id, ok := w.uidStore.GetKey(uid)
|
||||
if !ok {
|
||||
return fmt.Errorf("bug: unknown uid %d", uid)
|
||||
}
|
||||
patch := jmap.Patch{}
|
||||
for kw := range flagsToKeywords(flags) {
|
||||
path := fmt.Sprintf("keywords/%s", kw)
|
||||
if enable {
|
||||
patch[path] = true
|
||||
} else {
|
||||
patch[path] = nil
|
||||
}
|
||||
}
|
||||
patches[jmap.ID(id)] = patch
|
||||
}
|
||||
|
||||
req.Invoke(&email.Set{
|
||||
Account: w.accountId,
|
||||
Update: patches,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return checkNotUpdated(resp)
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) moveCopy(uids []uint32, destDir string, deleteSrc bool) error {
|
||||
var req jmap.Request
|
||||
var destMbox jmap.ID
|
||||
var destroy []jmap.ID
|
||||
var ok bool
|
||||
|
||||
patches := make(map[jmap.ID]jmap.Patch)
|
||||
|
||||
destMbox, ok = w.dir2mbox[destDir]
|
||||
if !ok && destDir != "" {
|
||||
return fmt.Errorf("unknown destination mailbox")
|
||||
}
|
||||
if destMbox != "" && destMbox == w.selectedMbox {
|
||||
return fmt.Errorf("cannot move to current mailbox")
|
||||
}
|
||||
|
||||
for _, uid := range uids {
|
||||
dest := destMbox
|
||||
id, ok := w.uidStore.GetKey(uid)
|
||||
if !ok {
|
||||
return fmt.Errorf("bug: unknown uid %d", uid)
|
||||
}
|
||||
mail, err := w.cache.GetEmail(jmap.ID(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bug: unknown message id %s: %w", id, err)
|
||||
}
|
||||
|
||||
patch := w.moveCopyPatch(mail, dest, deleteSrc)
|
||||
if len(patch) == 0 {
|
||||
destroy = append(destroy, mail.ID)
|
||||
w.w.Debugf("destroying <%s>", mail.MessageID[0])
|
||||
} else {
|
||||
patches[jmap.ID(id)] = patch
|
||||
}
|
||||
}
|
||||
|
||||
req.Invoke(&email.Set{
|
||||
Account: w.accountId,
|
||||
Update: patches,
|
||||
Destroy: destroy,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return checkNotUpdated(resp)
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) moveCopyPatch(
|
||||
mail *email.Email, dest jmap.ID, deleteSrc bool,
|
||||
) jmap.Patch {
|
||||
patch := jmap.Patch{}
|
||||
|
||||
if dest == "" && deleteSrc && len(mail.MailboxIDs) == 1 {
|
||||
dest = w.roles[mailbox.RoleTrash]
|
||||
}
|
||||
if dest != "" && dest != w.selectedMbox {
|
||||
d := w.mbox2dir[dest]
|
||||
if deleteSrc {
|
||||
w.w.Debugf("moving <%s> to %q", mail.MessageID[0], d)
|
||||
} else {
|
||||
w.w.Debugf("copying <%s> to %q", mail.MessageID[0], d)
|
||||
}
|
||||
patch[w.mboxPatch(dest)] = true
|
||||
}
|
||||
if deleteSrc && len(patch) > 0 {
|
||||
switch {
|
||||
case w.selectedMbox != "":
|
||||
patch[w.mboxPatch(w.selectedMbox)] = nil
|
||||
case len(mail.MailboxIDs) == 1:
|
||||
// In "all mail" virtual mailbox and email is in
|
||||
// a single mailbox, "Move" it to the specified
|
||||
// destination
|
||||
patch = jmap.Patch{"mailboxIds": []jmap.ID{dest}}
|
||||
default:
|
||||
// In "all mail" virtual mailbox and email is in
|
||||
// multiple mailboxes. Since we cannot know what mailbox
|
||||
// to remove, try at least to remove role=inbox.
|
||||
patch[w.rolePatch(mailbox.RoleInbox)] = nil
|
||||
}
|
||||
}
|
||||
|
||||
return patch
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) mboxPatch(mbox jmap.ID) string {
|
||||
return fmt.Sprintf("mailboxIds/%s", mbox)
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) rolePatch(role mailbox.Role) string {
|
||||
return fmt.Sprintf("mailboxIds/%s", w.roles[role])
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleModifyLabels(msg *types.ModifyLabels) error {
|
||||
var req jmap.Request
|
||||
patch := jmap.Patch{}
|
||||
|
||||
for _, a := range msg.Add {
|
||||
mboxId, ok := w.dir2mbox[a]
|
||||
if !ok {
|
||||
return fmt.Errorf("unkown label: %q", a)
|
||||
}
|
||||
patch[w.mboxPatch(mboxId)] = true
|
||||
}
|
||||
for _, r := range msg.Remove {
|
||||
mboxId, ok := w.dir2mbox[r]
|
||||
if !ok {
|
||||
return fmt.Errorf("unkown label: %q", r)
|
||||
}
|
||||
patch[w.mboxPatch(mboxId)] = nil
|
||||
}
|
||||
|
||||
patches := make(map[jmap.ID]jmap.Patch)
|
||||
|
||||
for _, uid := range msg.Uids {
|
||||
id, ok := w.uidStore.GetKey(uid)
|
||||
if !ok {
|
||||
return fmt.Errorf("bug: unknown uid %d", uid)
|
||||
}
|
||||
patches[jmap.ID(id)] = patch
|
||||
}
|
||||
|
||||
req.Invoke(&email.Set{
|
||||
Account: w.accountId,
|
||||
Update: patches,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return checkNotUpdated(resp)
|
||||
}
|
||||
|
||||
func checkNotUpdated(resp *jmap.Response) error {
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.SetResponse:
|
||||
for _, err := range r.NotUpdated {
|
||||
return wrapSetError(err)
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleAppendMessage(msg *types.AppendMessage) error {
|
||||
dest, ok := w.dir2mbox[msg.Destination]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown destination mailbox")
|
||||
}
|
||||
|
||||
// Upload the message
|
||||
blob, err := w.Upload(msg.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req jmap.Request
|
||||
|
||||
// Import the blob into specified directory
|
||||
req.Invoke(&email.Import{
|
||||
Account: w.accountId,
|
||||
Emails: map[string]*email.EmailImport{
|
||||
"aerc": {
|
||||
BlobID: blob.ID,
|
||||
MailboxIDs: map[jmap.ID]bool{dest: true},
|
||||
Keywords: flagsToKeywords(msg.Flags),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *email.ImportResponse:
|
||||
if err, ok := r.NotCreated["aerc"]; ok {
|
||||
return wrapSetError(err)
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
195
worker/jmap/worker.go
Normal file
195
worker/jmap/worker.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/uidstore"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/handlers"
|
||||
"git.sr.ht/~rjarry/aerc/worker/jmap/cache"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/identity"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
func init() {
|
||||
handlers.RegisterWorkerFactory("jmap", NewJMAPWorker)
|
||||
}
|
||||
|
||||
var errUnsupported = errors.New("unsupported")
|
||||
|
||||
type JMAPWorker struct {
|
||||
config struct {
|
||||
account *config.AccountConfig
|
||||
endpoint string
|
||||
oauth bool
|
||||
user *url.Userinfo
|
||||
cacheState bool
|
||||
cacheBlobs bool
|
||||
serverPing time.Duration
|
||||
useLabels bool
|
||||
allMail string
|
||||
}
|
||||
|
||||
w *types.Worker
|
||||
client *jmap.Client
|
||||
cache *cache.JMAPCache
|
||||
accountId jmap.ID
|
||||
|
||||
selectedMbox jmap.ID
|
||||
dir2mbox map[string]jmap.ID
|
||||
mbox2dir map[jmap.ID]string
|
||||
roles map[mailbox.Role]jmap.ID
|
||||
identities map[string]*identity.Identity
|
||||
uidStore *uidstore.Store
|
||||
|
||||
changes chan jmap.TypeState
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewJMAPWorker(worker *types.Worker) (types.Backend, error) {
|
||||
return &JMAPWorker{
|
||||
w: worker,
|
||||
uidStore: uidstore.NewStore(),
|
||||
roles: make(map[mailbox.Role]jmap.ID),
|
||||
dir2mbox: make(map[string]jmap.ID),
|
||||
mbox2dir: make(map[jmap.ID]string),
|
||||
identities: make(map[string]*identity.Identity),
|
||||
changes: make(chan jmap.TypeState),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) addMbox(mbox *mailbox.Mailbox, dir string) {
|
||||
w.mbox2dir[mbox.ID] = dir
|
||||
w.dir2mbox[dir] = mbox.ID
|
||||
w.roles[mbox.Role] = mbox.ID
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) deleteMbox(id jmap.ID) {
|
||||
var dir string
|
||||
var role mailbox.Role
|
||||
|
||||
delete(w.mbox2dir, id)
|
||||
for d, i := range w.dir2mbox {
|
||||
if i == id {
|
||||
dir = d
|
||||
break
|
||||
}
|
||||
}
|
||||
delete(w.dir2mbox, dir)
|
||||
for r, i := range w.roles {
|
||||
if i == id {
|
||||
role = r
|
||||
break
|
||||
}
|
||||
}
|
||||
delete(w.roles, role)
|
||||
}
|
||||
|
||||
var capas = models.Capabilities{Sort: true, Thread: false}
|
||||
|
||||
func (w *JMAPWorker) Capabilities() *models.Capabilities {
|
||||
return &capas
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) PathSeparator() string {
|
||||
return "/"
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleMessage(msg types.WorkerMessage) error {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Unsupported:
|
||||
// No-op
|
||||
break
|
||||
case *types.Configure:
|
||||
return w.handleConfigure(msg)
|
||||
case *types.Connect:
|
||||
if w.stop != nil {
|
||||
return errors.New("already connected")
|
||||
}
|
||||
return w.handleConnect(msg)
|
||||
case *types.Reconnect:
|
||||
if w.stop == nil {
|
||||
return errors.New("not connected")
|
||||
}
|
||||
close(w.stop)
|
||||
return w.handleConnect(&types.Connect{Message: msg.Message})
|
||||
case *types.Disconnect:
|
||||
if w.stop == nil {
|
||||
return errors.New("not connected")
|
||||
}
|
||||
close(w.stop)
|
||||
return nil
|
||||
case *types.ListDirectories:
|
||||
return w.handleListDirectories(msg)
|
||||
case *types.OpenDirectory:
|
||||
return w.handleOpenDirectory(msg)
|
||||
case *types.FetchDirectoryContents:
|
||||
return w.handleFetchDirectoryContents(msg)
|
||||
case *types.SearchDirectory:
|
||||
return w.handleSearchDirectory(msg)
|
||||
case *types.CreateDirectory:
|
||||
return w.handleCreateDirectory(msg)
|
||||
case *types.RemoveDirectory:
|
||||
return w.handleRemoveDirectory(msg)
|
||||
case *types.FetchMessageHeaders:
|
||||
return w.handleFetchMessageHeaders(msg)
|
||||
case *types.FetchMessageBodyPart:
|
||||
return w.handleFetchMessageBodyPart(msg)
|
||||
case *types.FetchFullMessages:
|
||||
return w.handleFetchFullMessages(msg)
|
||||
case *types.FlagMessages:
|
||||
return w.updateFlags(msg.Uids, msg.Flags, msg.Enable)
|
||||
case *types.AnsweredMessages:
|
||||
return w.updateFlags(msg.Uids, models.AnsweredFlag, msg.Answered)
|
||||
case *types.DeleteMessages:
|
||||
return w.moveCopy(msg.Uids, "", true)
|
||||
case *types.CopyMessages:
|
||||
return w.moveCopy(msg.Uids, msg.Destination, false)
|
||||
case *types.MoveMessages:
|
||||
return w.moveCopy(msg.Uids, msg.Destination, true)
|
||||
case *types.ModifyLabels:
|
||||
if w.config.useLabels {
|
||||
return w.handleModifyLabels(msg)
|
||||
}
|
||||
case *types.AppendMessage:
|
||||
return w.handleAppendMessage(msg)
|
||||
case *types.StartSendingMessage:
|
||||
return w.handleStartSend(msg)
|
||||
}
|
||||
return errUnsupported
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) Run() {
|
||||
for {
|
||||
select {
|
||||
case change := <-w.changes:
|
||||
err := w.refresh(change)
|
||||
if err != nil {
|
||||
w.w.Errorf("refresh: %s", err)
|
||||
}
|
||||
case msg := <-w.w.Actions:
|
||||
msg = w.w.ProcessAction(msg)
|
||||
err := w.handleMessage(msg)
|
||||
switch {
|
||||
case errors.Is(err, errUnsupported):
|
||||
w.w.PostMessage(&types.Unsupported{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
case err != nil:
|
||||
w.w.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
default:
|
||||
w.w.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type WorkerMessage interface {
|
||||
@@ -209,6 +210,11 @@ type CheckMail struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type StartSendingMessage struct {
|
||||
Message
|
||||
Header *mail.Header
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
type Directory struct {
|
||||
@@ -281,3 +287,8 @@ type CheckMailDirectories struct {
|
||||
Message
|
||||
Directories []string
|
||||
}
|
||||
|
||||
type MessageWriter struct {
|
||||
Message
|
||||
Writer io.WriteCloser
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package worker
|
||||
// the following workers are always enabled
|
||||
import (
|
||||
_ "git.sr.ht/~rjarry/aerc/worker/imap"
|
||||
_ "git.sr.ht/~rjarry/aerc/worker/jmap"
|
||||
_ "git.sr.ht/~rjarry/aerc/worker/lib/watchers"
|
||||
_ "git.sr.ht/~rjarry/aerc/worker/maildir"
|
||||
_ "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||
|
||||
Reference in New Issue
Block a user