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:
Robin Jarry
2023-06-04 14:05:22 +02:00
parent b4ae11b4ec
commit be0bfc1ae2
33 changed files with 2507 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, &copyBuf)
}
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(), &copyBuf)
@@ -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 {

View File

@@ -14,6 +14,7 @@ var pages = []string{
"binds",
"config",
"imap",
"jmap",
"notmuch",
"search",
"sendmail",

View File

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

View File

@@ -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
View 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/.

View File

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

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

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

View File

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

View File

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