init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package worker
|
||||
|
||||
import _ "git.sr.ht/~rjarry/aerc/worker/notmuch"
|
||||
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type FactoryFunc func(*types.Worker) (types.Backend, error)
|
||||
|
||||
var workerFactories map[string]FactoryFunc = make(map[string]FactoryFunc)
|
||||
|
||||
func RegisterWorkerFactory(scheme string, factory FactoryFunc) {
|
||||
workerFactories[scheme] = factory
|
||||
}
|
||||
|
||||
func GetHandlerForScheme(scheme string, worker *types.Worker) (types.Backend, error) {
|
||||
factory, ok := workerFactories[scheme]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Unknown backend %s", scheme)
|
||||
}
|
||||
backend, err := factory(worker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return backend, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/emersion/go-message/textproto"
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
)
|
||||
|
||||
type CachedHeader struct {
|
||||
BodyStructure models.BodyStructure
|
||||
Envelope models.Envelope
|
||||
InternalDate time.Time
|
||||
Uid models.UID
|
||||
Size uint32
|
||||
Header []byte
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
// cacheTag should be updated when changing the cache
|
||||
// structure; this will ensure that the user's cache is cleared and
|
||||
// reloaded when the underlying cache structure changes
|
||||
cacheTag = []byte("0003")
|
||||
cacheTagKey = []byte("cache.tag")
|
||||
)
|
||||
|
||||
// initCacheDb opens (or creates) the database for the cache. One database is
|
||||
// created per account
|
||||
func (w *IMAPWorker) initCacheDb(acct string) {
|
||||
switch {
|
||||
case len(w.config.headersExclude) > 0:
|
||||
headerTag := strings.Join(w.config.headersExclude, "")
|
||||
cacheTag = append(cacheTag, headerTag...)
|
||||
case len(w.config.headers) > 0:
|
||||
headerTag := strings.Join(w.config.headers, "")
|
||||
cacheTag = append(cacheTag, headerTag...)
|
||||
}
|
||||
p := xdg.CachePath("aerc", acct)
|
||||
db, err := leveldb.OpenFile(p, nil)
|
||||
if err != nil {
|
||||
w.cache = nil
|
||||
w.worker.Errorf("failed opening cache db: %v", err)
|
||||
return
|
||||
}
|
||||
w.cache = db
|
||||
w.worker.Debugf("cache db opened: %s", p)
|
||||
|
||||
tag, err := w.cache.Get(cacheTagKey, nil)
|
||||
clearCache := errors.Is(err, leveldb.ErrNotFound) ||
|
||||
!bytes.Equal(tag, cacheTag)
|
||||
switch {
|
||||
case clearCache:
|
||||
w.worker.Infof("current cache tag is '%s' but found '%s'",
|
||||
cacheTag, tag)
|
||||
w.worker.Warnf("tag mismatch: clear cache")
|
||||
w.clearCache()
|
||||
if err = w.cache.Put(cacheTagKey, cacheTag, nil); err != nil {
|
||||
w.worker.Errorf("could not set the current cache tag")
|
||||
}
|
||||
case err != nil:
|
||||
w.worker.Errorf("could not get the cache tag from db")
|
||||
default:
|
||||
if w.config.cacheMaxAge.Hours() > 0 {
|
||||
go w.cleanCache(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) cacheHeader(mi *models.MessageInfo) {
|
||||
key := w.headerKey(mi.Uid)
|
||||
w.worker.Debugf("caching header for message %s", key)
|
||||
hdr := bytes.NewBuffer(nil)
|
||||
err := textproto.WriteHeader(hdr, mi.RFC822Headers.Header.Header)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot write header %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
h := &CachedHeader{
|
||||
BodyStructure: *mi.BodyStructure,
|
||||
Envelope: *mi.Envelope,
|
||||
InternalDate: mi.InternalDate,
|
||||
Uid: mi.Uid,
|
||||
Size: mi.Size,
|
||||
Header: hdr.Bytes(),
|
||||
Created: time.Now(),
|
||||
}
|
||||
data := bytes.NewBuffer(nil)
|
||||
enc := gob.NewEncoder(data)
|
||||
err = enc.Encode(h)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot encode message %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
err = w.cache.Put(key, data.Bytes(), nil)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot write header for message %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) getCachedHeaders(msg *types.FetchMessageHeaders) []models.UID {
|
||||
w.worker.Tracef("Retrieving headers from cache: %v", msg.Uids)
|
||||
var need []models.UID
|
||||
for _, uid := range msg.Uids {
|
||||
key := w.headerKey(uid)
|
||||
data, err := w.cache.Get(key, nil)
|
||||
if err != nil {
|
||||
need = append(need, uid)
|
||||
continue
|
||||
}
|
||||
ch := &CachedHeader{}
|
||||
dec := gob.NewDecoder(bytes.NewReader(data))
|
||||
err = dec.Decode(ch)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot decode cached header %s: %v", key, err)
|
||||
need = append(need, uid)
|
||||
continue
|
||||
}
|
||||
hr := bytes.NewReader(ch.Header)
|
||||
textprotoHeader, err := textproto.ReadHeader(bufio.NewReader(hr))
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot read cached header %s: %v", key, err)
|
||||
need = append(need, uid)
|
||||
continue
|
||||
}
|
||||
|
||||
hdr := &mail.Header{Header: message.Header{Header: textprotoHeader}}
|
||||
mi := &models.MessageInfo{
|
||||
BodyStructure: &ch.BodyStructure,
|
||||
Envelope: &ch.Envelope,
|
||||
Flags: models.SeenFlag, // Always return a SEEN flag
|
||||
Uid: ch.Uid,
|
||||
RFC822Headers: hdr,
|
||||
Refs: parse.MsgIDList(hdr, "references"),
|
||||
Size: ch.Size,
|
||||
}
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: mi,
|
||||
NeedsFlags: true,
|
||||
}, nil)
|
||||
}
|
||||
return need
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) headerKey(uid models.UID) []byte {
|
||||
key := fmt.Sprintf("header.%s.%d.%s",
|
||||
w.selected.Name, w.selected.UidValidity, uid)
|
||||
return []byte(key)
|
||||
}
|
||||
|
||||
// cleanCache removes stale entries from the selected mailbox cachedb
|
||||
func (w *IMAPWorker) cleanCache(path string) {
|
||||
defer log.PanicHandler()
|
||||
start := time.Now()
|
||||
var scanned, removed int
|
||||
iter := w.cache.NewIterator(nil, nil)
|
||||
for iter.Next() {
|
||||
if bytes.Equal(iter.Key(), cacheTagKey) {
|
||||
continue
|
||||
}
|
||||
data := iter.Value()
|
||||
ch := &CachedHeader{}
|
||||
dec := gob.NewDecoder(bytes.NewReader(data))
|
||||
err := dec.Decode(ch)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot clean database %d: %v",
|
||||
w.selected.UidValidity, err)
|
||||
continue
|
||||
}
|
||||
exp := ch.Created.Add(w.config.cacheMaxAge)
|
||||
if exp.Before(time.Now()) {
|
||||
err = w.cache.Delete(iter.Key(), nil)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot clean database %d: %v",
|
||||
w.selected.UidValidity, err)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
}
|
||||
scanned++
|
||||
}
|
||||
iter.Release()
|
||||
elapsed := time.Since(start)
|
||||
w.worker.Debugf("%s: removed %d/%d expired entries in %s",
|
||||
path, removed, scanned, elapsed)
|
||||
}
|
||||
|
||||
// clearCache clears the entire cache
|
||||
func (w *IMAPWorker) clearCache() {
|
||||
iter := w.cache.NewIterator(nil, nil)
|
||||
for iter.Next() {
|
||||
if err := w.cache.Delete(iter.Key(), nil); err != nil {
|
||||
w.worker.Errorf("error clearing cache: %v", err)
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
func (w *IMAPWorker) handleCheckMailMessage(msg *types.CheckMail) {
|
||||
items := []imap.StatusItem{
|
||||
imap.StatusMessages,
|
||||
imap.StatusRecent,
|
||||
imap.StatusUnseen,
|
||||
imap.StatusUidNext,
|
||||
}
|
||||
var (
|
||||
statuses []*imap.MailboxStatus
|
||||
err error
|
||||
remaining []string
|
||||
)
|
||||
switch {
|
||||
case w.liststatus:
|
||||
w.worker.Tracef("Checking mail with LIST-STATUS")
|
||||
statuses, err = w.client.liststatus.ListStatus("", "*", items, nil)
|
||||
if err != nil {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
default:
|
||||
for _, dir := range msg.Directories {
|
||||
if len(w.worker.Actions()) > 0 {
|
||||
remaining = append(remaining, dir)
|
||||
continue
|
||||
}
|
||||
w.worker.Tracef("Getting status of directory %s", dir)
|
||||
status, err := w.client.Status(dir, items)
|
||||
if err != nil {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
continue
|
||||
}
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
}
|
||||
for _, status := range statuses {
|
||||
refetch := false
|
||||
if status.Name == w.selected.Name {
|
||||
if status.UidNext != w.selected.UidNext {
|
||||
refetch = true
|
||||
}
|
||||
w.selected = status
|
||||
}
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: &models.DirectoryInfo{
|
||||
Name: status.Name,
|
||||
Exists: int(status.Messages),
|
||||
Recent: int(status.Recent),
|
||||
Unseen: int(status.Unseen),
|
||||
},
|
||||
Refetch: refetch,
|
||||
}, nil)
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
w.worker.PostMessage(&types.CheckMailDirectories{
|
||||
Message: types.RespondTo(msg),
|
||||
Directories: remaining,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
"git.sr.ht/~rjarry/aerc/worker/middleware"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func (w *IMAPWorker) handleConfigure(msg *types.Configure) error {
|
||||
w.config.name = msg.Config.Name
|
||||
u, err := url.Parse(msg.Config.Source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.config.scheme = u.Scheme
|
||||
if strings.HasSuffix(w.config.scheme, "+insecure") {
|
||||
w.config.scheme = strings.TrimSuffix(w.config.scheme, "+insecure")
|
||||
w.config.insecure = true
|
||||
}
|
||||
|
||||
if strings.HasSuffix(w.config.scheme, "+oauthbearer") {
|
||||
w.config.scheme = strings.TrimSuffix(w.config.scheme, "+oauthbearer")
|
||||
w.config.oauthBearer.Enabled = true
|
||||
q := u.Query()
|
||||
|
||||
oauth2 := &oauth2.Config{}
|
||||
if q.Get("token_endpoint") != "" {
|
||||
oauth2.ClientID = q.Get("client_id")
|
||||
oauth2.ClientSecret = q.Get("client_secret")
|
||||
oauth2.Scopes = []string{q.Get("scope")}
|
||||
oauth2.Endpoint.TokenURL = q.Get("token_endpoint")
|
||||
}
|
||||
w.config.oauthBearer.OAuth2 = oauth2
|
||||
}
|
||||
|
||||
if strings.HasSuffix(w.config.scheme, "+xoauth2") {
|
||||
w.config.scheme = strings.TrimSuffix(w.config.scheme, "+xoauth2")
|
||||
w.config.xoauth2.Enabled = true
|
||||
q := u.Query()
|
||||
|
||||
oauth2 := &oauth2.Config{}
|
||||
if q.Get("token_endpoint") != "" {
|
||||
oauth2.ClientID = q.Get("client_id")
|
||||
oauth2.ClientSecret = q.Get("client_secret")
|
||||
oauth2.Scopes = []string{q.Get("scope")}
|
||||
oauth2.Endpoint.TokenURL = q.Get("token_endpoint")
|
||||
}
|
||||
w.config.xoauth2.OAuth2 = oauth2
|
||||
}
|
||||
|
||||
w.config.addr = u.Host
|
||||
if !strings.ContainsRune(w.config.addr, ':') {
|
||||
w.config.addr += ":" + w.config.scheme
|
||||
}
|
||||
|
||||
w.config.user = u.User
|
||||
w.config.folders = msg.Config.Folders
|
||||
w.config.headers = msg.Config.Headers
|
||||
w.config.headersExclude = msg.Config.HeadersExclude
|
||||
|
||||
w.config.idle_timeout = 10 * time.Second
|
||||
w.config.idle_debounce = 10 * time.Millisecond
|
||||
|
||||
w.config.connection_timeout = 30 * time.Second
|
||||
w.config.keepalive_period = 0 * time.Second
|
||||
w.config.keepalive_probes = 3
|
||||
w.config.keepalive_interval = 3
|
||||
|
||||
w.config.reconnect_maxwait = 30 * time.Second
|
||||
|
||||
w.config.cacheEnabled = false
|
||||
w.config.cacheMaxAge = 30 * 24 * time.Hour // 30 days
|
||||
|
||||
for key, value := range msg.Config.Params {
|
||||
switch key {
|
||||
case "idle-timeout":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid idle-timeout value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.idle_timeout = val
|
||||
case "idle-debounce":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid idle-debounce value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.idle_debounce = val
|
||||
case "reconnect-maxwait":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid reconnect-maxwait value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.reconnect_maxwait = val
|
||||
case "connection-timeout":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid connection-timeout value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.connection_timeout = val
|
||||
case "keepalive-period":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid keepalive-period value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.keepalive_period = val
|
||||
case "keepalive-probes":
|
||||
val, err := strconv.Atoi(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid keepalive-probes value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.keepalive_probes = val
|
||||
case "keepalive-interval":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf(
|
||||
"invalid keepalive-interval value %v: %w",
|
||||
value, err)
|
||||
}
|
||||
w.config.keepalive_interval = int(val.Seconds())
|
||||
case "cache-headers":
|
||||
cache, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
// Return an error here because the user tried to set header
|
||||
// caching, and we want them to know they didn't set it right -
|
||||
// one way or the other
|
||||
return fmt.Errorf("invalid cache-headers value %v: %w", value, err)
|
||||
}
|
||||
w.config.cacheEnabled = cache
|
||||
case "cache-max-age":
|
||||
val, err := time.ParseDuration(value)
|
||||
if err != nil || val < 0 {
|
||||
return fmt.Errorf("invalid cache-max-age value %v: %w", value, err)
|
||||
}
|
||||
w.config.cacheMaxAge = val
|
||||
case "use-gmail-ext":
|
||||
val, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid use-gmail-ext value %v: %w", value, err)
|
||||
}
|
||||
w.config.useXGMEXT = val
|
||||
}
|
||||
}
|
||||
if w.config.cacheEnabled {
|
||||
w.initCacheDb(msg.Config.Name)
|
||||
}
|
||||
w.idler = newIdler(w.config, w.worker, w.executeIdle)
|
||||
w.observer = newObserver(w.config, w.worker)
|
||||
|
||||
if name, ok := msg.Config.Params["folder-map"]; ok {
|
||||
file := xdg.ExpandHome(name)
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fmap, order, err := lib.ParseFolderMap(bufio.NewReader(f))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.worker = middleware.NewFolderMapper(w.worker, fmap, order)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-sasl"
|
||||
)
|
||||
|
||||
// connect establishes a new tcp connection to the imap server, logs in and
|
||||
// selects the default inbox. If no error is returned, the imap client will be
|
||||
// in the imap.SelectedState.
|
||||
func (w *IMAPWorker) connect() (*client.Client, error) {
|
||||
var (
|
||||
conn *net.TCPConn
|
||||
err error
|
||||
c *client.Client
|
||||
)
|
||||
|
||||
conn, err = newTCPConn(w.config.addr, w.config.connection_timeout)
|
||||
if conn == nil || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if w.config.connection_timeout > 0 {
|
||||
end := time.Now().Add(w.config.connection_timeout)
|
||||
err = conn.SetDeadline(end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if w.config.keepalive_period > 0 {
|
||||
err = w.setKeepaliveParameters(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
serverName, _, _ := net.SplitHostPort(w.config.addr)
|
||||
tlsConfig := &tls.Config{ServerName: serverName}
|
||||
|
||||
switch w.config.scheme {
|
||||
case "imap":
|
||||
c, err = client.New(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !w.config.insecure {
|
||||
if err = c.StartTLS(tlsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case "imaps":
|
||||
if w.config.insecure {
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
}
|
||||
tlsConn := tls.Client(conn, tlsConfig)
|
||||
c, err = client.New(tlsConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("Unknown IMAP scheme %s", w.config.scheme)
|
||||
}
|
||||
|
||||
c.ErrorLog = log.ErrorLogger()
|
||||
|
||||
if w.config.user != nil {
|
||||
username := w.config.user.Username()
|
||||
|
||||
// TODO: 2nd parameter false if no password is set. ask for it
|
||||
// if unset.
|
||||
password, _ := w.config.user.Password()
|
||||
|
||||
if w.config.oauthBearer.Enabled {
|
||||
if err := w.config.oauthBearer.Authenticate(
|
||||
username, password, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if w.config.xoauth2.Enabled {
|
||||
if err := w.config.xoauth2.Authenticate(
|
||||
username, password, w.config.name, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if plain, err := c.SupportAuth("PLAIN"); err != nil {
|
||||
return nil, err
|
||||
} else if plain {
|
||||
auth := sasl.NewPlainClient("", username, password)
|
||||
if err := c.Authenticate(auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := c.Login(username, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := c.Select(imap.InboxName, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := make(chan *imap.MailboxInfo, 1)
|
||||
if err := c.List("", "", info); err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve delimiter: %w", err)
|
||||
}
|
||||
if mailboxinfo := <-info; mailboxinfo != nil {
|
||||
w.delimiter = mailboxinfo.Delimiter
|
||||
}
|
||||
if w.delimiter == "" {
|
||||
// just in case some implementation does not follow standards
|
||||
w.delimiter = "/"
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// newTCPConn establishes a new tcp connection. Timeout will ensure that the
|
||||
// function does not hang when there is no connection. If there is a timeout,
|
||||
// but a valid connection is eventually returned, ensure that it is properly
|
||||
// closed.
|
||||
func newTCPConn(addr string, timeout time.Duration) (*net.TCPConn, error) {
|
||||
errTCPTimeout := fmt.Errorf("tcp connection timeout")
|
||||
|
||||
type tcpConn struct {
|
||||
conn *net.TCPConn
|
||||
err error
|
||||
}
|
||||
|
||||
done := make(chan tcpConn)
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
newConn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
done <- tcpConn{nil, err}
|
||||
return
|
||||
}
|
||||
done <- tcpConn{newConn.(*net.TCPConn), nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
if tcpResult := <-done; tcpResult.conn != nil {
|
||||
tcpResult.conn.Close()
|
||||
}
|
||||
}()
|
||||
return nil, errTCPTimeout
|
||||
case tcpResult := <-done:
|
||||
if tcpResult.conn == nil || tcpResult.err != nil {
|
||||
return nil, tcpResult.err
|
||||
}
|
||||
return tcpResult.conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Set additional keepalive parameters.
|
||||
// Uses new interfaces introduced in Go1.11, which let us get connection's file
|
||||
// descriptor, without blocking, and therefore without uncontrolled spawning of
|
||||
// threads (not goroutines, actual threads).
|
||||
func (w *IMAPWorker) setKeepaliveParameters(conn *net.TCPConn) error {
|
||||
err := conn.SetKeepAlive(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Idle time before sending a keepalive probe
|
||||
err = conn.SetKeepAlivePeriod(w.config.keepalive_period)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawConn, e := conn.SyscallConn()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
err = rawConn.Control(func(fdPtr uintptr) {
|
||||
fd := int(fdPtr)
|
||||
// Max number of probes before failure
|
||||
err := lib.SetTcpKeepaliveProbes(fd, w.config.keepalive_probes)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot set tcp keepalive probes: %v", err)
|
||||
}
|
||||
// Wait time after an unsuccessful probe
|
||||
err = lib.SetTcpKeepaliveInterval(fd, w.config.keepalive_interval)
|
||||
if err != nil {
|
||||
w.worker.Errorf("cannot set tcp keepalive interval: %v", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (imapw *IMAPWorker) handleCreateDirectory(msg *types.CreateDirectory) {
|
||||
if err := imapw.client.Create(msg.Directory); err != nil {
|
||||
if msg.Quiet {
|
||||
return
|
||||
}
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
} else {
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-imap/responses"
|
||||
"github.com/emersion/go-imap/utf7"
|
||||
)
|
||||
|
||||
// A LIST-STATUS client
|
||||
type ListStatusClient struct {
|
||||
c *client.Client
|
||||
}
|
||||
|
||||
func NewListStatusClient(c *client.Client) *ListStatusClient {
|
||||
return &ListStatusClient{c}
|
||||
}
|
||||
|
||||
// SupportListStatus checks if the server supports the LIST-STATUS extension.
|
||||
func (c *ListStatusClient) SupportListStatus() (bool, error) {
|
||||
return c.c.Support("LIST-STATUS")
|
||||
}
|
||||
|
||||
// ListStatus performs a LIST-STATUS command, listing mailboxes and also
|
||||
// retrieving the requested status items. A nil channel can be passed in order
|
||||
// to only retrieve the STATUS responses
|
||||
func (c *ListStatusClient) ListStatus(
|
||||
ref string,
|
||||
name string,
|
||||
items []imap.StatusItem,
|
||||
ch chan *imap.MailboxInfo,
|
||||
) ([]*imap.MailboxStatus, error) {
|
||||
if ch != nil {
|
||||
defer close(ch)
|
||||
}
|
||||
|
||||
if c.c.State() != imap.AuthenticatedState && c.c.State() != imap.SelectedState {
|
||||
return nil, client.ErrNotLoggedIn
|
||||
}
|
||||
|
||||
cmd := &ListStatusCommand{
|
||||
Reference: ref,
|
||||
Mailbox: name,
|
||||
Items: items,
|
||||
}
|
||||
res := &ListStatusResponse{Mailboxes: ch}
|
||||
|
||||
status, err := c.c.Execute(cmd, res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.Statuses, status.Err()
|
||||
}
|
||||
|
||||
// ListStatusCommand is a LIST command, as defined in RFC 3501 section 6.3.8. If
|
||||
// Subscribed is set to true, LSUB will be used instead. Mailbox statuses will
|
||||
// be returned if Items is not nil
|
||||
type ListStatusCommand struct {
|
||||
Reference string
|
||||
Mailbox string
|
||||
|
||||
Subscribed bool
|
||||
Items []imap.StatusItem
|
||||
}
|
||||
|
||||
func (cmd *ListStatusCommand) Command() *imap.Command {
|
||||
name := "LIST"
|
||||
if cmd.Subscribed {
|
||||
name = "LSUB"
|
||||
}
|
||||
|
||||
enc := utf7.Encoding.NewEncoder()
|
||||
ref, _ := enc.String(cmd.Reference)
|
||||
mailbox, _ := enc.String(cmd.Mailbox)
|
||||
|
||||
items := make([]string, len(cmd.Items))
|
||||
if cmd.Items != nil {
|
||||
for i, item := range cmd.Items {
|
||||
items[i] = string(item)
|
||||
}
|
||||
}
|
||||
|
||||
args := fmt.Sprintf("RETURN (STATUS (%s))", strings.Join(items, " "))
|
||||
return &imap.Command{
|
||||
Name: name,
|
||||
Arguments: []interface{}{ref, mailbox, imap.RawString(args)},
|
||||
}
|
||||
}
|
||||
|
||||
// A LIST-STATUS response
|
||||
type ListStatusResponse struct {
|
||||
Mailboxes chan *imap.MailboxInfo
|
||||
Subscribed bool
|
||||
Statuses []*imap.MailboxStatus
|
||||
}
|
||||
|
||||
func (r *ListStatusResponse) Name() string {
|
||||
if r.Subscribed {
|
||||
return "LSUB"
|
||||
} else {
|
||||
return "LIST"
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ListStatusResponse) Handle(resp imap.Resp) error {
|
||||
name, _, ok := imap.ParseNamedResp(resp)
|
||||
if !ok {
|
||||
return responses.ErrUnhandled
|
||||
}
|
||||
switch name {
|
||||
case "LIST":
|
||||
if r.Mailboxes == nil {
|
||||
return nil
|
||||
}
|
||||
res := responses.List{Mailboxes: r.Mailboxes}
|
||||
return res.Handle(resp)
|
||||
case "STATUS":
|
||||
res := responses.Status{
|
||||
Mailbox: new(imap.MailboxStatus),
|
||||
}
|
||||
err := res.Handle(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Statuses = append(r.Statuses, res.Mailbox)
|
||||
default:
|
||||
return responses.ErrUnhandled
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ListStatusResponse) WriteTo(w *imap.Writer) error {
|
||||
respName := r.Name()
|
||||
|
||||
for mbox := range r.Mailboxes {
|
||||
fields := []interface{}{imap.RawString(respName)}
|
||||
fields = append(fields, mbox.Format()...)
|
||||
|
||||
resp := imap.NewUntaggedResp(fields)
|
||||
if err := resp.WriteTo(w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package xgmext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-imap/commands"
|
||||
"github.com/emersion/go-imap/responses"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func NewHandler(c *client.Client) *handler {
|
||||
return &handler{client: c}
|
||||
}
|
||||
|
||||
func (h handler) FetchEntireThreads(requested []models.UID) ([]models.UID, error) {
|
||||
threadIds, err := h.fetchThreadIds(requested)
|
||||
if err != nil {
|
||||
return nil,
|
||||
fmt.Errorf("failed to fetch thread IDs: %w", err)
|
||||
}
|
||||
uids, err := h.searchUids(threadIds)
|
||||
if err != nil {
|
||||
return nil,
|
||||
fmt.Errorf("failed to search for thread IDs: %w", err)
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
func (h handler) fetchThreadIds(uids []models.UID) ([]string, error) {
|
||||
messages := make(chan *imap.Message)
|
||||
done := make(chan error)
|
||||
|
||||
thriditem := imap.FetchItem("X-GM-THRID")
|
||||
items := []imap.FetchItem{
|
||||
thriditem,
|
||||
}
|
||||
|
||||
m := make(map[string]struct{}, len(uids))
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
for msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
item, ok := msg.Items[thriditem].(string)
|
||||
if ok {
|
||||
m[item] = struct{}{}
|
||||
}
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
var set imap.SeqSet
|
||||
for _, uid := range uids {
|
||||
set.AddNum(models.UidToUint32(uid))
|
||||
}
|
||||
err := h.client.UidFetch(&set, items, messages)
|
||||
<-done
|
||||
|
||||
thrid := make([]string, 0, len(m))
|
||||
for id := range m {
|
||||
thrid = append(thrid, id)
|
||||
}
|
||||
return thrid, err
|
||||
}
|
||||
|
||||
func (h handler) searchUids(thrid []string) ([]models.UID, error) {
|
||||
if len(thrid) == 0 {
|
||||
return nil, errors.New("no thread IDs provided")
|
||||
}
|
||||
return h.runSearch(NewThreadIDSearch(thrid))
|
||||
}
|
||||
|
||||
func (h handler) RawSearch(rawSearch string) ([]models.UID, error) {
|
||||
return h.runSearch(NewRawSearch(rawSearch))
|
||||
}
|
||||
|
||||
func (h handler) runSearch(cmd imap.Commander) ([]models.UID, error) {
|
||||
if h.client.State() != imap.SelectedState {
|
||||
return nil, errors.New("no mailbox selected")
|
||||
}
|
||||
cmd = &commands.Uid{Cmd: cmd}
|
||||
res := new(responses.Search)
|
||||
status, err := h.client.Execute(cmd, res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("imap execute failed: %w", err)
|
||||
}
|
||||
var uids []models.UID
|
||||
for _, i := range res.Ids {
|
||||
uids = append(uids, models.Uint32ToUid(i))
|
||||
}
|
||||
return uids, status.Err()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package xgmext
|
||||
|
||||
import "github.com/emersion/go-imap"
|
||||
|
||||
type threadIDSearch struct {
|
||||
Charset string
|
||||
ThreadIDs []string
|
||||
}
|
||||
|
||||
// NewThreadIDSearch return an imap.Command to search UIDs for the provided
|
||||
// thread IDs using the X-GM-EXT-1 (Gmail extension)
|
||||
func NewThreadIDSearch(threadIDs []string) *threadIDSearch {
|
||||
return &threadIDSearch{
|
||||
Charset: "UTF-8",
|
||||
ThreadIDs: threadIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func (cmd *threadIDSearch) Command() *imap.Command {
|
||||
const threadSearchKey = "X-GM-THRID"
|
||||
|
||||
var args []interface{}
|
||||
if cmd.Charset != "" {
|
||||
args = append(args, imap.RawString("CHARSET"))
|
||||
args = append(args, imap.RawString(cmd.Charset))
|
||||
}
|
||||
|
||||
// we want to produce a search query that looks like this:
|
||||
// SEARCH CHARSET UTF-8 OR OR X-GM-THRID 1771431779961568536 \
|
||||
// X-GM-THRID 1765355745646219617 X-GM-THRID 1771500774375286796
|
||||
for i := 0; i < len(cmd.ThreadIDs)-1; i++ {
|
||||
args = append(args, imap.RawString("OR"))
|
||||
}
|
||||
|
||||
for _, thrid := range cmd.ThreadIDs {
|
||||
args = append(args, imap.RawString(threadSearchKey))
|
||||
args = append(args, imap.RawString(thrid))
|
||||
}
|
||||
|
||||
return &imap.Command{
|
||||
Name: "SEARCH",
|
||||
Arguments: args,
|
||||
}
|
||||
}
|
||||
|
||||
type rawSearch struct {
|
||||
Charset string
|
||||
Search string
|
||||
}
|
||||
|
||||
func NewRawSearch(search string) *rawSearch {
|
||||
return &rawSearch{
|
||||
Charset: "UTF-8",
|
||||
Search: search,
|
||||
}
|
||||
}
|
||||
|
||||
func (cmd *rawSearch) Command() *imap.Command {
|
||||
const key = "X-GM-RAW"
|
||||
|
||||
var args []interface{}
|
||||
if cmd.Charset != "" {
|
||||
args = append(args, imap.RawString("CHARSET"))
|
||||
args = append(args, imap.RawString(cmd.Charset))
|
||||
}
|
||||
|
||||
args = append(args, imap.RawString(key))
|
||||
args = append(args, imap.RawString(cmd.Search))
|
||||
|
||||
return &imap.Command{
|
||||
Name: "SEARCH",
|
||||
Arguments: args,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package xgmext_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
func TestXGMEXT_ThreadIDSearch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ids []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "search for single id",
|
||||
ids: []string{"1234"},
|
||||
want: "* SEARCH CHARSET UTF-8 X-GM-THRID 1234\r\n",
|
||||
},
|
||||
{
|
||||
name: "search for multiple id",
|
||||
ids: []string{"1234", "5678", "2345"},
|
||||
want: "* SEARCH CHARSET UTF-8 OR OR X-GM-THRID 1234 X-GM-THRID 5678 X-GM-THRID 2345\r\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
cmd := xgmext.NewThreadIDSearch(test.ids).Command()
|
||||
var buf bytes.Buffer
|
||||
err := cmd.WriteTo(imap.NewWriter(&buf))
|
||||
if err != nil {
|
||||
t.Errorf("failed to write command: %v", err)
|
||||
}
|
||||
if got := buf.String(); got != test.want {
|
||||
t.Errorf("test '%s' failed: got: '%s', but wanted: '%s'",
|
||||
test.name, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXGMEXT_RawSearch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
search string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "search messages from mailing list",
|
||||
search: "list:info@example.com",
|
||||
want: "* SEARCH CHARSET UTF-8 X-GM-RAW list:info@example.com\r\n",
|
||||
},
|
||||
{
|
||||
name: "search for an exact phrase",
|
||||
search: "\"good morning\"",
|
||||
want: "* SEARCH CHARSET UTF-8 X-GM-RAW \"good morning\"\r\n",
|
||||
},
|
||||
{
|
||||
name: "group multiple search terms together",
|
||||
search: "subject:(dinner movie)",
|
||||
want: "* SEARCH CHARSET UTF-8 X-GM-RAW subject:(dinner movie)\r\n",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
cmd := xgmext.NewRawSearch(test.search).Command()
|
||||
var buf bytes.Buffer
|
||||
err := cmd.WriteTo(imap.NewWriter(&buf))
|
||||
if err != nil {
|
||||
t.Errorf("failed to write command: %v", err)
|
||||
}
|
||||
if got := buf.String(); got != test.want {
|
||||
t.Errorf("test '%s' failed: got: '%s', but wanted: '%s'",
|
||||
test.name, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package xgmext
|
||||
|
||||
var Terms = []string{
|
||||
"from:",
|
||||
"to:",
|
||||
"cc:",
|
||||
"bcc:",
|
||||
"subject:",
|
||||
"label:",
|
||||
"deliveredto:",
|
||||
"category:primary",
|
||||
"category:social",
|
||||
"category:promotions",
|
||||
"category:updates",
|
||||
"category:forums",
|
||||
"category:reservations",
|
||||
"category:purchases",
|
||||
"has:",
|
||||
"has:attachment",
|
||||
"has:drive",
|
||||
"has:document",
|
||||
"has:spreadsheet",
|
||||
"has:presentation",
|
||||
"has:youtube",
|
||||
"list:",
|
||||
"filename:",
|
||||
"in:",
|
||||
"is:",
|
||||
"is:important",
|
||||
"is:read",
|
||||
"is:unread",
|
||||
"is:starred",
|
||||
"after:",
|
||||
"before:",
|
||||
"older:",
|
||||
"newer:",
|
||||
"older_than:",
|
||||
"newer_than:",
|
||||
"size:",
|
||||
"larger:",
|
||||
"smaller:",
|
||||
"rfc822msgid:",
|
||||
"OR",
|
||||
"AND",
|
||||
"AROUND",
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-message"
|
||||
_ "github.com/emersion/go-message/charset"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/emersion/go-message/textproto"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (imapw *IMAPWorker) handleFetchMessageHeaders(
|
||||
msg *types.FetchMessageHeaders,
|
||||
) {
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
toFetch := msg.Uids
|
||||
if imapw.config.cacheEnabled && imapw.cache != nil {
|
||||
toFetch = imapw.getCachedHeaders(msg)
|
||||
}
|
||||
if len(toFetch) == 0 {
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)},
|
||||
nil)
|
||||
return
|
||||
}
|
||||
imapw.worker.Tracef("Fetching message headers: %v", toFetch)
|
||||
hdrBodyPart := imap.BodyPartName{
|
||||
Specifier: imap.HeaderSpecifier,
|
||||
}
|
||||
switch {
|
||||
case len(imapw.config.headersExclude) > 0:
|
||||
hdrBodyPart.NotFields = true
|
||||
hdrBodyPart.Fields = imapw.config.headersExclude
|
||||
case len(imapw.config.headers) > 0:
|
||||
hdrBodyPart.Fields = imapw.config.headers
|
||||
}
|
||||
section := &imap.BodySectionName{
|
||||
BodyPartName: hdrBodyPart,
|
||||
Peek: true,
|
||||
}
|
||||
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchBodyStructure,
|
||||
imap.FetchEnvelope,
|
||||
imap.FetchInternalDate,
|
||||
imap.FetchFlags,
|
||||
imap.FetchUid,
|
||||
imap.FetchRFC822Size,
|
||||
section.FetchItem(),
|
||||
}
|
||||
imapw.handleFetchMessages(msg, toFetch, items,
|
||||
func(_msg *imap.Message) error {
|
||||
if len(_msg.Body) == 0 {
|
||||
// ignore duplicate messages with only flag updates
|
||||
return nil
|
||||
}
|
||||
reader := _msg.GetBody(section)
|
||||
if reader == nil {
|
||||
return fmt.Errorf("failed to find part: %v", section)
|
||||
}
|
||||
textprotoHeader, err := textproto.ReadHeader(bufio.NewReader(reader))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read part header: %w", err)
|
||||
}
|
||||
header := &mail.Header{Header: message.Header{Header: textprotoHeader}}
|
||||
info := &models.MessageInfo{
|
||||
BodyStructure: translateBodyStructure(_msg.BodyStructure),
|
||||
Envelope: translateEnvelope(_msg.Envelope),
|
||||
Flags: translateImapFlags(_msg.Flags),
|
||||
InternalDate: _msg.InternalDate,
|
||||
RFC822Headers: header,
|
||||
Refs: parse.MsgIDList(header, "references"),
|
||||
Size: _msg.Size,
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
}
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: info,
|
||||
}, nil)
|
||||
if imapw.config.cacheEnabled && imapw.cache != nil {
|
||||
imapw.cacheHeader(info)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleFetchMessageBodyPart(
|
||||
msg *types.FetchMessageBodyPart,
|
||||
) {
|
||||
imapw.worker.Tracef("Fetching message %d part: %v", msg.Uid, msg.Part)
|
||||
|
||||
var partHeaderSection imap.BodySectionName
|
||||
partHeaderSection.Peek = true
|
||||
if len(msg.Part) > 0 {
|
||||
partHeaderSection.Specifier = imap.MIMESpecifier
|
||||
} else {
|
||||
partHeaderSection.Specifier = imap.HeaderSpecifier
|
||||
}
|
||||
partHeaderSection.Path = msg.Part
|
||||
|
||||
var partBodySection imap.BodySectionName
|
||||
if len(msg.Part) > 0 {
|
||||
partBodySection.Specifier = imap.EntireSpecifier
|
||||
} else {
|
||||
partBodySection.Specifier = imap.TextSpecifier
|
||||
}
|
||||
partBodySection.Path = msg.Part
|
||||
partBodySection.Peek = true
|
||||
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchEnvelope,
|
||||
imap.FetchUid,
|
||||
imap.FetchBodyStructure,
|
||||
imap.FetchFlags,
|
||||
partHeaderSection.FetchItem(),
|
||||
partBodySection.FetchItem(),
|
||||
}
|
||||
imapw.handleFetchMessages(msg, []models.UID{msg.Uid}, items,
|
||||
func(_msg *imap.Message) error {
|
||||
if len(_msg.Body) == 0 {
|
||||
// ignore duplicate messages with only flag updates
|
||||
return nil
|
||||
}
|
||||
body := _msg.GetBody(&partHeaderSection)
|
||||
if body == nil {
|
||||
return fmt.Errorf("failed to find part: %v", partHeaderSection)
|
||||
}
|
||||
h, err := textproto.ReadHeader(bufio.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read part header: %w", err)
|
||||
}
|
||||
|
||||
part, err := message.New(message.Header{Header: h},
|
||||
_msg.GetBody(&partBodySection))
|
||||
if message.IsUnknownCharset(err) {
|
||||
imapw.worker.Warnf("unknown charset encountered "+
|
||||
"for uid %d", _msg.Uid)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to create message reader: %w", err)
|
||||
}
|
||||
|
||||
imapw.worker.PostMessage(&types.MessageBodyPart{
|
||||
Message: types.RespondTo(msg),
|
||||
Part: &models.MessageBodyPart{
|
||||
Reader: part.Body,
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
// Update flags (to mark message as read)
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Flags: translateImapFlags(_msg.Flags),
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleFetchFullMessages(
|
||||
msg *types.FetchFullMessages,
|
||||
) {
|
||||
imapw.worker.Tracef("Fetching full messages: %v", msg.Uids)
|
||||
section := &imap.BodySectionName{
|
||||
Peek: true,
|
||||
}
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchEnvelope,
|
||||
imap.FetchFlags,
|
||||
imap.FetchUid,
|
||||
section.FetchItem(),
|
||||
}
|
||||
imapw.handleFetchMessages(msg, msg.Uids, items,
|
||||
func(_msg *imap.Message) error {
|
||||
if len(_msg.Body) == 0 {
|
||||
// ignore duplicate messages with only flag updates
|
||||
return nil
|
||||
}
|
||||
r := _msg.GetBody(section)
|
||||
if r == nil {
|
||||
return fmt.Errorf("could not get section %#v", section)
|
||||
}
|
||||
imapw.worker.PostMessage(&types.FullMessage{
|
||||
Message: types.RespondTo(msg),
|
||||
Content: &models.FullMessage{
|
||||
Reader: bufio.NewReader(r),
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
// Update flags (to mark message as read)
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Flags: translateImapFlags(_msg.Flags),
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleFetchMessageFlags(msg *types.FetchMessageFlags) {
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchFlags,
|
||||
imap.FetchUid,
|
||||
}
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
imapw.handleFetchMessages(msg, msg.Uids, items,
|
||||
func(_msg *imap.Message) error {
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Flags: translateImapFlags(_msg.Flags),
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleFetchMessages(
|
||||
msg types.WorkerMessage, uids []models.UID, items []imap.FetchItem,
|
||||
procFunc func(*imap.Message) error,
|
||||
) {
|
||||
messages := make(chan *imap.Message)
|
||||
done := make(chan struct{})
|
||||
|
||||
missingUids := make(map[models.UID]bool)
|
||||
for _, uid := range uids {
|
||||
missingUids[uid] = true
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
for _msg := range messages {
|
||||
delete(missingUids, models.Uint32ToUid(_msg.Uid))
|
||||
err := procFunc(_msg)
|
||||
if err != nil {
|
||||
log.Errorf("failed to process message <%d>: %v", _msg.Uid, err)
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
Error: err,
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
set := toSeqSet(uids)
|
||||
if err := imapw.client.UidFetch(set, items, messages); err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
<-done
|
||||
|
||||
for uid := range missingUids {
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Uid: uid,
|
||||
Error: fmt.Errorf("invalid response from server (detailed error in log)"),
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
imapw.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
// drainUpdates will drain the updates channel. For some operations, the imap
|
||||
// server will send unilateral messages. If they arrive while another operation
|
||||
// is in progress, the buffered updates channel can fill up and cause a freeze
|
||||
// of the entire backend. Avoid this by draining the updates channel and only
|
||||
// process the Message and Expunge updates.
|
||||
//
|
||||
// To stop the draining, close the returned struct.
|
||||
func (imapw *IMAPWorker) drainUpdates() *drainCloser {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
for {
|
||||
select {
|
||||
case update := <-imapw.updates:
|
||||
switch update.(type) {
|
||||
case *client.MessageUpdate,
|
||||
*client.ExpungeUpdate:
|
||||
imapw.handleImapUpdate(update)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return &drainCloser{done}
|
||||
}
|
||||
|
||||
type drainCloser struct {
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (d *drainCloser) Close() error {
|
||||
close(d.done)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleDeleteMessages(msg *types.DeleteMessages) {
|
||||
drain := imapw.drainUpdates()
|
||||
defer drain.Close()
|
||||
|
||||
item := imap.FormatFlagsOp(imap.AddFlags, true)
|
||||
flags := []interface{}{imap.DeletedFlag}
|
||||
uids := toSeqSet(msg.Uids)
|
||||
if err := imapw.client.UidStore(uids, item, flags, nil); err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
if err := imapw.client.Expunge(nil); err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
} else {
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleAnsweredMessages(msg *types.AnsweredMessages) {
|
||||
item := imap.FormatFlagsOp(imap.AddFlags, false)
|
||||
flags := []interface{}{imap.AnsweredFlag}
|
||||
if !msg.Answered {
|
||||
item = imap.FormatFlagsOp(imap.RemoveFlags, false)
|
||||
}
|
||||
imapw.handleStoreOps(msg, msg.Uids, item, flags,
|
||||
func(_msg *imap.Message) error {
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Flags: translateImapFlags(_msg.Flags),
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleFlagMessages(msg *types.FlagMessages) {
|
||||
flags := []interface{}{flagToImap[msg.Flags]}
|
||||
item := imap.FormatFlagsOp(imap.AddFlags, false)
|
||||
if !msg.Enable {
|
||||
item = imap.FormatFlagsOp(imap.RemoveFlags, false)
|
||||
}
|
||||
imapw.handleStoreOps(msg, msg.Uids, item, flags,
|
||||
func(_msg *imap.Message) error {
|
||||
imapw.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: &models.MessageInfo{
|
||||
Flags: translateImapFlags(_msg.Flags),
|
||||
Uid: models.Uint32ToUid(_msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleStoreOps(
|
||||
msg types.WorkerMessage, uids []models.UID, item imap.StoreItem, flag interface{},
|
||||
procFunc func(*imap.Message) error,
|
||||
) {
|
||||
messages := make(chan *imap.Message)
|
||||
done := make(chan error)
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
var reterr error
|
||||
for _msg := range messages {
|
||||
err := procFunc(_msg)
|
||||
if err != nil {
|
||||
if reterr == nil {
|
||||
reterr = err
|
||||
}
|
||||
// drain the channel upon error
|
||||
for range messages {
|
||||
}
|
||||
}
|
||||
}
|
||||
done <- reterr
|
||||
}()
|
||||
|
||||
emitErr := func(err error) {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
set := toSeqSet(uids)
|
||||
if err := imapw.client.UidStore(set, item, flag, messages); err != nil {
|
||||
emitErr(err)
|
||||
return
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
emitErr(err)
|
||||
return
|
||||
}
|
||||
imapw.worker.PostAction(&types.CheckMail{
|
||||
Directories: []string{imapw.selected.Name},
|
||||
}, nil)
|
||||
imapw.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
var errIdleTimeout = fmt.Errorf("idle timeout")
|
||||
|
||||
// idler manages the idle mode of the imap server. Enter idle mode if there's
|
||||
// no other task and leave idle mode when a new task arrives. Idle mode is only
|
||||
// used when the client is ready and connected. After a connection loss, make
|
||||
// sure that idling returns gracefully and the worker remains responsive.
|
||||
type idler struct {
|
||||
client *imapClient
|
||||
debouncer *time.Timer
|
||||
debounce time.Duration
|
||||
timeout time.Duration
|
||||
worker types.WorkerInteractor
|
||||
stop chan struct{}
|
||||
start chan struct{}
|
||||
done chan error
|
||||
}
|
||||
|
||||
func newIdler(cfg imapConfig, w types.WorkerInteractor, startIdler chan struct{}) *idler {
|
||||
return &idler{
|
||||
debouncer: nil,
|
||||
debounce: cfg.idle_debounce,
|
||||
timeout: cfg.idle_timeout,
|
||||
worker: w,
|
||||
stop: make(chan struct{}),
|
||||
start: startIdler,
|
||||
done: make(chan error),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *idler) SetClient(c *imapClient) {
|
||||
i.client = c
|
||||
}
|
||||
|
||||
func (i *idler) ready() bool {
|
||||
return (i.client != nil && i.client.State() == imap.SelectedState)
|
||||
}
|
||||
|
||||
func (i *idler) Start() {
|
||||
if !i.ready() {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-i.stop:
|
||||
// stop channel is nil (probably after a debounce), we don't
|
||||
// want to close it
|
||||
default:
|
||||
close(i.stop)
|
||||
}
|
||||
|
||||
// create new stop channel
|
||||
i.stop = make(chan struct{})
|
||||
|
||||
// clear done channel
|
||||
clearing := true
|
||||
for clearing {
|
||||
select {
|
||||
case <-i.done:
|
||||
continue
|
||||
default:
|
||||
clearing = false
|
||||
}
|
||||
}
|
||||
|
||||
i.worker.Tracef("idler (start): start idle after debounce")
|
||||
i.debouncer = time.AfterFunc(i.debounce, func() {
|
||||
i.start <- struct{}{}
|
||||
i.worker.Tracef("idler (start): started")
|
||||
})
|
||||
}
|
||||
|
||||
func (i *idler) Execute() {
|
||||
if !i.ready() {
|
||||
return
|
||||
}
|
||||
|
||||
// we need to call client.Idle in a goroutine since it is blocking call
|
||||
// and we still want to receive messages
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
start := time.Now()
|
||||
err := i.client.Idle(i.stop, nil)
|
||||
if err != nil {
|
||||
i.worker.Errorf("idle returned error: %v", err)
|
||||
}
|
||||
i.worker.Tracef("idler (execute): idleing for %s", time.Since(start))
|
||||
|
||||
i.done <- err
|
||||
}()
|
||||
}
|
||||
|
||||
func (i *idler) Stop() error {
|
||||
if !i.ready() {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-i.stop:
|
||||
i.worker.Debugf("idler (stop): idler already stopped?")
|
||||
return nil
|
||||
default:
|
||||
close(i.stop)
|
||||
}
|
||||
|
||||
if i.debouncer != nil {
|
||||
if i.debouncer.Stop() {
|
||||
i.worker.Tracef("idler (stop): debounced")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-i.done:
|
||||
i.worker.Tracef("idler (stop): idle stopped: %v", err)
|
||||
return err
|
||||
case <-time.After(i.timeout):
|
||||
i.worker.Errorf("idler (stop): cannot stop idle (timeout)")
|
||||
return errIdleTimeout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/charset"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
func init() {
|
||||
imap.CharsetReader = charset.Reader
|
||||
}
|
||||
|
||||
func toSeqSet(uids []models.UID) *imap.SeqSet {
|
||||
set := new(imap.SeqSet)
|
||||
for _, uid := range uids {
|
||||
set.AddNum(models.UidToUint32(uid))
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func translateBodyStructure(bs *imap.BodyStructure) *models.BodyStructure {
|
||||
if bs == nil {
|
||||
return nil
|
||||
}
|
||||
var parts []*models.BodyStructure
|
||||
for _, part := range bs.Parts {
|
||||
parts = append(parts, translateBodyStructure(part))
|
||||
}
|
||||
|
||||
// TODO: is that all?
|
||||
|
||||
return &models.BodyStructure{
|
||||
MIMEType: bs.MIMEType,
|
||||
MIMESubType: bs.MIMESubType,
|
||||
Params: bs.Params,
|
||||
Description: bs.Description,
|
||||
Encoding: bs.Encoding,
|
||||
Parts: parts,
|
||||
Disposition: bs.Disposition,
|
||||
DispositionParams: bs.DispositionParams,
|
||||
}
|
||||
}
|
||||
|
||||
func translateEnvelope(e *imap.Envelope) *models.Envelope {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.Envelope{
|
||||
Date: e.Date,
|
||||
Subject: e.Subject,
|
||||
From: translateAddresses(e.From),
|
||||
ReplyTo: translateAddresses(e.ReplyTo),
|
||||
To: translateAddresses(e.To),
|
||||
Cc: translateAddresses(e.Cc),
|
||||
Bcc: translateAddresses(e.Bcc),
|
||||
MessageId: translateMessageID(e.MessageId),
|
||||
InReplyTo: translateMessageID(e.InReplyTo),
|
||||
}
|
||||
}
|
||||
|
||||
func translateMessageID(messageID string) string {
|
||||
// Strip away unwanted characters, go-message expects the message id
|
||||
// without brackets, spaces, tabs and new lines.
|
||||
return strings.Trim(messageID, "<> \t\r\n")
|
||||
}
|
||||
|
||||
func translateAddresses(addrs []*imap.Address) []*mail.Address {
|
||||
var converted []*mail.Address
|
||||
for _, addr := range addrs {
|
||||
converted = append(converted, &mail.Address{
|
||||
Name: addr.PersonalName,
|
||||
Address: addr.Address(),
|
||||
})
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
var imapToFlag = map[string]models.Flags{
|
||||
imap.SeenFlag: models.SeenFlag,
|
||||
imap.RecentFlag: models.RecentFlag,
|
||||
imap.AnsweredFlag: models.AnsweredFlag,
|
||||
imap.DeletedFlag: models.DeletedFlag,
|
||||
imap.FlaggedFlag: models.FlaggedFlag,
|
||||
imap.DraftFlag: models.DraftFlag,
|
||||
}
|
||||
|
||||
var flagToImap = map[models.Flags]string{
|
||||
models.SeenFlag: imap.SeenFlag,
|
||||
models.RecentFlag: imap.RecentFlag,
|
||||
models.AnsweredFlag: imap.AnsweredFlag,
|
||||
models.DeletedFlag: imap.DeletedFlag,
|
||||
models.FlaggedFlag: imap.FlaggedFlag,
|
||||
models.DraftFlag: imap.DraftFlag,
|
||||
}
|
||||
|
||||
func translateImapFlags(imapFlags []string) models.Flags {
|
||||
var flags models.Flags
|
||||
for _, imapFlag := range imapFlags {
|
||||
if flag, ok := imapToFlag[imapFlag]; ok {
|
||||
flags |= flag
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func translateFlags(flags models.Flags) []string {
|
||||
var imapFlags []string
|
||||
for flag, imapFlag := range flagToImap {
|
||||
if flags.Has(flag) {
|
||||
imapFlags = append(imapFlags, imapFlag)
|
||||
}
|
||||
}
|
||||
return imapFlags
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/mail"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTranslateEnvelope(t *testing.T) {
|
||||
date, _ := time.Parse("2010-01-31", "1992-10-24")
|
||||
givenAddress := imap.Address{
|
||||
PersonalName: "PERSONAL_NAME",
|
||||
AtDomainList: "AT_DOMAIN_LIST",
|
||||
MailboxName: "MAILBOX_NAME",
|
||||
HostName: "HOST_NAME",
|
||||
}
|
||||
givenMessageID := " \r\n\r \t <initial-message-id@with-leading-space>\t\r"
|
||||
given := imap.Envelope{
|
||||
Date: date,
|
||||
Subject: "Test Subject",
|
||||
From: []*imap.Address{&givenAddress},
|
||||
ReplyTo: []*imap.Address{&givenAddress},
|
||||
To: []*imap.Address{&givenAddress},
|
||||
Cc: []*imap.Address{&givenAddress},
|
||||
Bcc: []*imap.Address{&givenAddress},
|
||||
MessageId: givenMessageID,
|
||||
InReplyTo: givenMessageID,
|
||||
}
|
||||
expectedMessageID := "initial-message-id@with-leading-space"
|
||||
expectedAddress := mail.Address{
|
||||
Name: "PERSONAL_NAME",
|
||||
Address: "MAILBOX_NAME@HOST_NAME",
|
||||
}
|
||||
expected := models.Envelope{
|
||||
Date: date,
|
||||
Subject: "Test Subject",
|
||||
From: []*mail.Address{&expectedAddress},
|
||||
ReplyTo: []*mail.Address{&expectedAddress},
|
||||
To: []*mail.Address{&expectedAddress},
|
||||
Cc: []*mail.Address{&expectedAddress},
|
||||
Bcc: []*mail.Address{&expectedAddress},
|
||||
MessageId: expectedMessageID,
|
||||
InReplyTo: expectedMessageID,
|
||||
}
|
||||
assert.Equal(t, &expected, translateEnvelope(&given))
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (imapw *IMAPWorker) handleListDirectories(msg *types.ListDirectories) {
|
||||
mailboxes := make(chan *imap.MailboxInfo)
|
||||
imapw.worker.Tracef("Listing mailboxes")
|
||||
done := make(chan interface{})
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
for mbox := range mailboxes {
|
||||
if !canOpen(mbox) {
|
||||
// no need to pass this to handlers if it can't be opened
|
||||
continue
|
||||
}
|
||||
dir := &models.Directory{
|
||||
Name: mbox.Name,
|
||||
}
|
||||
for _, attr := range mbox.Attributes {
|
||||
attr = strings.TrimPrefix(attr, "\\")
|
||||
attr = strings.ToLower(attr)
|
||||
role, ok := models.Roles[attr]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dir.Role = role
|
||||
}
|
||||
if mbox.Name == "INBOX" {
|
||||
dir.Role = models.InboxRole
|
||||
}
|
||||
imapw.worker.PostMessage(&types.Directory{
|
||||
Message: types.RespondTo(msg),
|
||||
Dir: dir,
|
||||
}, nil)
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
switch {
|
||||
case imapw.liststatus:
|
||||
items := []imap.StatusItem{
|
||||
imap.StatusMessages,
|
||||
imap.StatusRecent,
|
||||
imap.StatusUnseen,
|
||||
}
|
||||
statuses, err := imapw.client.liststatus.ListStatus(
|
||||
"",
|
||||
"*",
|
||||
items,
|
||||
mailboxes,
|
||||
)
|
||||
if err != nil {
|
||||
<-done
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
|
||||
}
|
||||
for _, status := range statuses {
|
||||
imapw.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: &models.DirectoryInfo{
|
||||
Name: status.Name,
|
||||
Exists: int(status.Messages),
|
||||
Recent: int(status.Recent),
|
||||
Unseen: int(status.Unseen),
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
default:
|
||||
err := imapw.client.List("", "*", mailboxes)
|
||||
if err != nil {
|
||||
<-done
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
<-done
|
||||
imapw.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
|
||||
const NonExistentAttr = "\\NonExistent"
|
||||
|
||||
func canOpen(mbox *imap.MailboxInfo) bool {
|
||||
for _, attr := range mbox.Attributes {
|
||||
if attr == imap.NoSelectAttr ||
|
||||
attr == NonExistentAttr {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) {
|
||||
emitError := func(err error) {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
imapw.worker.Tracef("Executing search")
|
||||
criteria := translateSearch(msg.Criteria)
|
||||
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
uids, err := imapw.client.UidSearch(criteria)
|
||||
if err != nil {
|
||||
emitError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
imapw.worker.PostMessage(&types.SearchResults{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: models.Uint32ToUidList(uids),
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (imapw *IMAPWorker) handleCopyMessages(msg *types.CopyMessages) {
|
||||
uids := toSeqSet(msg.Uids)
|
||||
if err := imapw.client.UidCopy(uids, msg.Destination); err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
} else {
|
||||
imapw.worker.PostMessage(&types.MessagesCopied{
|
||||
Message: types.RespondTo(msg),
|
||||
Destination: msg.Destination,
|
||||
Uids: msg.Uids,
|
||||
}, nil)
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
type appendLiteral struct {
|
||||
io.Reader
|
||||
Length int
|
||||
}
|
||||
|
||||
func (m appendLiteral) Len() int {
|
||||
return m.Length
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleAppendMessage(msg *types.AppendMessage) {
|
||||
if err := imapw.client.Append(msg.Destination, translateFlags(msg.Flags), msg.Date,
|
||||
&appendLiteral{
|
||||
Reader: msg.Reader,
|
||||
Length: msg.Length,
|
||||
}); err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
} else {
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleMoveMessages(msg *types.MoveMessages) {
|
||||
drain := imapw.drainUpdates()
|
||||
defer drain.Close()
|
||||
|
||||
uids := toSeqSet(msg.Uids)
|
||||
if err := imapw.client.UidMove(uids, msg.Destination); err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
} else {
|
||||
imapw.worker.PostMessage(&types.MessagesMoved{
|
||||
Message: types.RespondTo(msg),
|
||||
Destination: msg.Destination,
|
||||
Uids: msg.Uids,
|
||||
}, nil)
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-imap"
|
||||
)
|
||||
|
||||
// observer monitors the loggedOut channel of the imap client. If the logout
|
||||
// signal is received, the observer will emit a connection error to the ui in
|
||||
// order to start the reconnect cycle.
|
||||
type observer struct {
|
||||
sync.Mutex
|
||||
config imapConfig
|
||||
client *imapClient
|
||||
worker types.WorkerInteractor
|
||||
done chan struct{}
|
||||
autoReconnect bool
|
||||
retries int
|
||||
running bool
|
||||
}
|
||||
|
||||
func newObserver(cfg imapConfig, w types.WorkerInteractor) *observer {
|
||||
return &observer{config: cfg, worker: w, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (o *observer) SetClient(c *imapClient) {
|
||||
o.Stop()
|
||||
o.Lock()
|
||||
o.client = c
|
||||
o.Unlock()
|
||||
o.Start()
|
||||
o.retries = 0
|
||||
}
|
||||
|
||||
func (o *observer) SetAutoReconnect(auto bool) {
|
||||
o.autoReconnect = auto
|
||||
}
|
||||
|
||||
func (o *observer) AutoReconnect() bool {
|
||||
return o.autoReconnect
|
||||
}
|
||||
|
||||
func (o *observer) isClientConnected() bool {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
return o.client != nil && o.client.State() == imap.SelectedState
|
||||
}
|
||||
|
||||
func (o *observer) EmitIfNotConnected() bool {
|
||||
if !o.isClientConnected() {
|
||||
o.emit("imap client not connected: attempt reconnect")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *observer) IsRunning() bool {
|
||||
return o.running
|
||||
}
|
||||
|
||||
func (o *observer) Start() {
|
||||
if o.running {
|
||||
return
|
||||
}
|
||||
if o.client == nil {
|
||||
return
|
||||
}
|
||||
if o.EmitIfNotConnected() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
select {
|
||||
case <-o.client.LoggedOut():
|
||||
if o.autoReconnect {
|
||||
o.emit("logged out")
|
||||
} else {
|
||||
o.log("ignore logout (auto-reconnect off)")
|
||||
}
|
||||
case <-o.done:
|
||||
break
|
||||
}
|
||||
o.running = false
|
||||
o.log("stopped")
|
||||
}()
|
||||
o.running = true
|
||||
o.log("started")
|
||||
}
|
||||
|
||||
func (o *observer) Stop() {
|
||||
if o.client == nil {
|
||||
return
|
||||
}
|
||||
if o.done != nil {
|
||||
close(o.done)
|
||||
}
|
||||
o.done = make(chan struct{})
|
||||
o.running = false
|
||||
}
|
||||
|
||||
func (o *observer) DelayedReconnect() error {
|
||||
var wait time.Duration
|
||||
var reterr error
|
||||
|
||||
if o.retries > 0 {
|
||||
backoff := int(math.Pow(1.8, float64(o.retries)))
|
||||
var err error
|
||||
wait, err = time.ParseDuration(fmt.Sprintf("%ds", backoff))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wait > o.config.reconnect_maxwait {
|
||||
wait = o.config.reconnect_maxwait
|
||||
}
|
||||
|
||||
reterr = fmt.Errorf("reconnect in %v", wait)
|
||||
} else {
|
||||
reterr = fmt.Errorf("reconnect")
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
<-time.After(wait)
|
||||
o.emit(reterr.Error())
|
||||
}()
|
||||
|
||||
o.retries++
|
||||
return reterr
|
||||
}
|
||||
|
||||
func (o *observer) emit(errMsg string) {
|
||||
o.worker.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(&types.Disconnect{}),
|
||||
}, nil)
|
||||
o.worker.PostMessage(&types.ConnError{
|
||||
Error: fmt.Errorf("%s", errMsg),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (o *observer) log(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
o.worker.Tracef("observer (%p) [running:%t] %s", o, o.running, msg)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
sortthread "github.com/emersion/go-imap-sortthread"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (imapw *IMAPWorker) handleOpenDirectory(msg *types.OpenDirectory) {
|
||||
imapw.worker.Debugf("Opening %s", msg.Directory)
|
||||
|
||||
sel, err := imapw.client.Select(msg.Directory, false)
|
||||
if err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-msg.Context.Done():
|
||||
imapw.worker.PostMessage(&types.Cancelled{Message: types.RespondTo(msg)}, nil)
|
||||
default:
|
||||
imapw.selected = sel
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleFetchDirectoryContents(
|
||||
msg *types.FetchDirectoryContents,
|
||||
) {
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
imapw.worker.Tracef("Fetching UID list")
|
||||
|
||||
searchCriteria := translateSearch(msg.Filter)
|
||||
sortCriteria := translateSortCriterions(msg.SortCriteria)
|
||||
hasSortCriteria := len(sortCriteria) > 0
|
||||
|
||||
var err error
|
||||
var uids []uint32
|
||||
|
||||
// If the server supports the SORT extension, do the sorting server side
|
||||
switch {
|
||||
case imapw.caps.Sort && hasSortCriteria:
|
||||
uids, err = imapw.client.sort.UidSort(sortCriteria, searchCriteria)
|
||||
if err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
// copy in reverse as msgList displays backwards
|
||||
for i, j := 0, len(uids)-1; i < j; i, j = i+1, j-1 {
|
||||
uids[i], uids[j] = uids[j], uids[i]
|
||||
}
|
||||
default:
|
||||
if hasSortCriteria {
|
||||
imapw.worker.Warnf("SORT is not supported but requested: list messages by UID")
|
||||
}
|
||||
uids, err = imapw.client.UidSearch(searchCriteria)
|
||||
if err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
imapw.worker.Tracef("Found %d UIDs", len(uids))
|
||||
if msg.Filter == nil {
|
||||
// Only initialize if we are not filtering
|
||||
imapw.seqMap.Initialize(uids)
|
||||
}
|
||||
|
||||
imapw.worker.PostMessage(&types.DirectoryContents{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: models.Uint32ToUidList(uids),
|
||||
}, nil)
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
|
||||
type sortFieldMapT map[types.SortField]sortthread.SortField
|
||||
|
||||
// caution, incomplete mapping
|
||||
var sortFieldMap sortFieldMapT = sortFieldMapT{
|
||||
types.SortArrival: sortthread.SortArrival,
|
||||
types.SortCc: sortthread.SortCc,
|
||||
types.SortDate: sortthread.SortDate,
|
||||
types.SortFrom: sortthread.SortFrom,
|
||||
types.SortSize: sortthread.SortSize,
|
||||
types.SortSubject: sortthread.SortSubject,
|
||||
types.SortTo: sortthread.SortTo,
|
||||
}
|
||||
|
||||
func translateSortCriterions(
|
||||
cs []*types.SortCriterion,
|
||||
) []sortthread.SortCriterion {
|
||||
result := make([]sortthread.SortCriterion, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
if f, ok := sortFieldMap[c.Field]; ok {
|
||||
result = append(result, sortthread.SortCriterion{Field: f, Reverse: c.Reverse})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (imapw *IMAPWorker) handleDirectoryThreaded(
|
||||
msg *types.FetchDirectoryThreaded,
|
||||
) {
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
imapw.worker.Tracef("Fetching threaded UID list")
|
||||
|
||||
searchCriteria := translateSearch(msg.Filter)
|
||||
threads, err := imapw.client.thread.UidThread(imapw.threadAlgorithm,
|
||||
searchCriteria)
|
||||
if err != nil {
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
aercThreads, count := convertThreads(threads, nil)
|
||||
sort.Sort(types.ByUID(aercThreads))
|
||||
imapw.worker.Tracef("Found %d threaded messages", count)
|
||||
if msg.Filter == nil {
|
||||
// Only initialize if we are not filtering
|
||||
var uids []uint32
|
||||
for i := len(aercThreads) - 1; i >= 0; i-- {
|
||||
aercThreads[i].Walk(func(t *types.Thread, level int, currentErr error) error { //nolint:errcheck // error indicates skipped threads
|
||||
uids = append(uids, models.UidToUint32(t.Uid))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
imapw.seqMap.Initialize(uids)
|
||||
}
|
||||
if msg.Context.Err() != nil {
|
||||
imapw.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
imapw.worker.PostMessage(&types.DirectoryThreaded{
|
||||
Message: types.RespondTo(msg),
|
||||
Threads: aercThreads,
|
||||
}, nil)
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
|
||||
func convertThreads(threads []*sortthread.Thread, parent *types.Thread) ([]*types.Thread, int) {
|
||||
if threads == nil {
|
||||
return nil, 0
|
||||
}
|
||||
conv := make([]*types.Thread, len(threads))
|
||||
count := 0
|
||||
|
||||
for i := 0; i < len(threads); i++ {
|
||||
t := threads[i]
|
||||
conv[i] = &types.Thread{
|
||||
Uid: models.Uint32ToUid(t.Id),
|
||||
}
|
||||
|
||||
// Set the first child node
|
||||
children, childCount := convertThreads(t.Children, conv[i])
|
||||
if len(children) > 0 {
|
||||
conv[i].FirstChild = children[0]
|
||||
}
|
||||
|
||||
// Set the parent node
|
||||
if parent != nil {
|
||||
conv[i].Parent = parent
|
||||
|
||||
// elements of threads are siblings
|
||||
if i > 0 {
|
||||
conv[i].PrevSibling = conv[i-1]
|
||||
conv[i-1].NextSibling = conv[i]
|
||||
}
|
||||
}
|
||||
|
||||
count += childCount + 1
|
||||
}
|
||||
return conv, count
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (imapw *IMAPWorker) handleRemoveDirectory(msg *types.RemoveDirectory) {
|
||||
if err := imapw.client.Delete(msg.Directory); err != nil {
|
||||
if msg.Quiet {
|
||||
return
|
||||
}
|
||||
imapw.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
} else {
|
||||
imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
func translateSearch(c *types.SearchCriteria) *imap.SearchCriteria {
|
||||
criteria := imap.NewSearchCriteria()
|
||||
if c == nil {
|
||||
return criteria
|
||||
}
|
||||
criteria.WithFlags = translateFlags(c.WithFlags)
|
||||
criteria.WithoutFlags = translateFlags(c.WithoutFlags)
|
||||
|
||||
if !c.StartDate.IsZero() {
|
||||
criteria.SentSince = c.StartDate
|
||||
}
|
||||
if !c.StartDate.IsZero() {
|
||||
criteria.SentBefore = c.EndDate
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
criteria.Header[k] = v
|
||||
}
|
||||
for _, f := range c.From {
|
||||
criteria.Header.Add("From", f)
|
||||
}
|
||||
for _, t := range c.To {
|
||||
criteria.Header.Add("To", t)
|
||||
}
|
||||
for _, c := range c.Cc {
|
||||
criteria.Header.Add("Cc", c)
|
||||
}
|
||||
terms := opt.LexArgs(strings.Join(c.Terms, " "))
|
||||
if terms.Count() > 0 {
|
||||
switch {
|
||||
case c.SearchAll:
|
||||
criteria.Text = terms.Args()
|
||||
case c.SearchBody:
|
||||
criteria.Body = terms.Args()
|
||||
default:
|
||||
for _, term := range terms.Args() {
|
||||
criteria.Header.Add("Subject", term)
|
||||
}
|
||||
}
|
||||
}
|
||||
return criteria
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type SeqMap struct {
|
||||
lock sync.Mutex
|
||||
// map of IMAP sequence numbers to message UIDs
|
||||
m []uint32
|
||||
}
|
||||
|
||||
// Initialize sets the initial seqmap of the mailbox
|
||||
func (s *SeqMap) Initialize(uids []uint32) {
|
||||
s.lock.Lock()
|
||||
s.m = make([]uint32, len(uids))
|
||||
copy(s.m, uids)
|
||||
s.sort()
|
||||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
func (s *SeqMap) Size() int {
|
||||
s.lock.Lock()
|
||||
size := len(s.m)
|
||||
s.lock.Unlock()
|
||||
return size
|
||||
}
|
||||
|
||||
// Get returns the UID of the given seqnum
|
||||
func (s *SeqMap) Get(seqnum uint32) (uint32, bool) {
|
||||
if int(seqnum) > s.Size() || seqnum < 1 {
|
||||
return 0, false
|
||||
}
|
||||
s.lock.Lock()
|
||||
uid := s.m[seqnum-1]
|
||||
s.lock.Unlock()
|
||||
return uid, true
|
||||
}
|
||||
|
||||
// Put adds a UID to the slice. Put should only be used to add new messages
|
||||
// into the slice
|
||||
func (s *SeqMap) Put(uid uint32) {
|
||||
s.lock.Lock()
|
||||
for _, n := range s.m {
|
||||
if n == uid {
|
||||
// We already have this UID, don't insert it.
|
||||
s.lock.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
s.m = append(s.m, uid)
|
||||
s.sort()
|
||||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
// Pop removes seqnum from the SeqMap. seqnum must be a valid seqnum, ie
|
||||
// [1:size of mailbox]
|
||||
func (s *SeqMap) Pop(seqnum uint32) (uint32, bool) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
if int(seqnum) > len(s.m) || seqnum < 1 {
|
||||
return 0, false
|
||||
}
|
||||
uid := s.m[seqnum-1]
|
||||
s.m = append(s.m[:seqnum-1], s.m[seqnum:]...)
|
||||
return uid, true
|
||||
}
|
||||
|
||||
// sort sorts the slice in ascending UID order. See:
|
||||
// https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.1.2
|
||||
func (s *SeqMap) sort() {
|
||||
// Always be sure the SeqMap is sorted
|
||||
sort.Slice(s.m, func(i, j int) bool {
|
||||
return s.m[i] < s.m[j]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSeqMap(t *testing.T) {
|
||||
var seqmap SeqMap
|
||||
var uid uint32
|
||||
var found bool
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(0, seqmap.Size())
|
||||
|
||||
_, found = seqmap.Get(42)
|
||||
assert.Equal(false, found)
|
||||
|
||||
_, found = seqmap.Pop(0)
|
||||
assert.Equal(false, found)
|
||||
|
||||
uids := []uint32{1337, 42, 1107}
|
||||
seqmap.Initialize(uids)
|
||||
assert.Equal(3, seqmap.Size())
|
||||
// Original list should remain unsorted
|
||||
assert.Equal([]uint32{1337, 42, 1107}, uids)
|
||||
|
||||
_, found = seqmap.Pop(0)
|
||||
assert.Equal(false, found)
|
||||
|
||||
uid, found = seqmap.Get(1)
|
||||
assert.Equal(42, int(uid))
|
||||
assert.Equal(true, found)
|
||||
|
||||
uid, found = seqmap.Pop(1)
|
||||
assert.Equal(42, int(uid))
|
||||
assert.Equal(true, found)
|
||||
assert.Equal(2, seqmap.Size())
|
||||
|
||||
uid, found = seqmap.Get(1)
|
||||
assert.Equal(1107, int(uid))
|
||||
|
||||
// Repeated puts of the same UID shouldn't change the size
|
||||
seqmap.Put(1231)
|
||||
assert.Equal(3, seqmap.Size())
|
||||
seqmap.Put(1231)
|
||||
assert.Equal(3, seqmap.Size())
|
||||
|
||||
uid, found = seqmap.Get(2)
|
||||
assert.Equal(1231, int(uid))
|
||||
|
||||
_, found = seqmap.Pop(1)
|
||||
assert.Equal(true, found)
|
||||
assert.Equal(2, seqmap.Size())
|
||||
|
||||
seqmap.Initialize(nil)
|
||||
assert.Equal(0, seqmap.Size())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
seqmap.Initialize([]uint32{42, 1337})
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, found := seqmap.Pop(1); !found; _, found = seqmap.Pop(1) {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, found := seqmap.Pop(1); !found; _, found = seqmap.Pop(1) {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(0, seqmap.Size())
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package imap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
sortthread "github.com/emersion/go-imap-sortthread"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/handlers"
|
||||
"git.sr.ht/~rjarry/aerc/worker/imap/extensions"
|
||||
"git.sr.ht/~rjarry/aerc/worker/middleware"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
handlers.RegisterWorkerFactory("imap", NewIMAPWorker)
|
||||
handlers.RegisterWorkerFactory("imaps", NewIMAPWorker)
|
||||
}
|
||||
|
||||
var (
|
||||
errUnsupported = fmt.Errorf("unsupported command")
|
||||
errClientNotReady = fmt.Errorf("client not ready")
|
||||
errNotConnected = fmt.Errorf("not connected")
|
||||
errAlreadyConnected = fmt.Errorf("already connected")
|
||||
)
|
||||
|
||||
type imapClient struct {
|
||||
*client.Client
|
||||
thread *sortthread.ThreadClient
|
||||
sort *sortthread.SortClient
|
||||
liststatus *extensions.ListStatusClient
|
||||
}
|
||||
|
||||
type imapConfig struct {
|
||||
name string
|
||||
scheme string
|
||||
insecure bool
|
||||
addr string
|
||||
user *url.Userinfo
|
||||
headers []string
|
||||
headersExclude []string
|
||||
folders []string
|
||||
oauthBearer lib.OAuthBearer
|
||||
xoauth2 lib.Xoauth2
|
||||
idle_timeout time.Duration
|
||||
idle_debounce time.Duration
|
||||
reconnect_maxwait time.Duration
|
||||
// tcp connection parameters
|
||||
connection_timeout time.Duration
|
||||
keepalive_period time.Duration
|
||||
keepalive_probes int
|
||||
keepalive_interval int
|
||||
cacheEnabled bool
|
||||
cacheMaxAge time.Duration
|
||||
useXGMEXT bool
|
||||
}
|
||||
|
||||
type IMAPWorker struct {
|
||||
config imapConfig
|
||||
|
||||
client *imapClient
|
||||
selected *imap.MailboxStatus
|
||||
updates chan client.Update
|
||||
worker types.WorkerInteractor
|
||||
seqMap SeqMap
|
||||
delimiter string
|
||||
|
||||
idler *idler
|
||||
observer *observer
|
||||
cache *leveldb.DB
|
||||
|
||||
caps *models.Capabilities
|
||||
|
||||
threadAlgorithm sortthread.ThreadAlgorithm
|
||||
liststatus bool
|
||||
|
||||
executeIdle chan struct{}
|
||||
}
|
||||
|
||||
func NewIMAPWorker(worker *types.Worker) (types.Backend, error) {
|
||||
return &IMAPWorker{
|
||||
updates: make(chan client.Update, 50),
|
||||
worker: worker,
|
||||
selected: &imap.MailboxStatus{},
|
||||
idler: nil, // will be set in configure()
|
||||
observer: nil, // will be set in configure()
|
||||
caps: &models.Capabilities{},
|
||||
executeIdle: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) newClient(c *client.Client) {
|
||||
c.Updates = nil
|
||||
w.client = &imapClient{
|
||||
c,
|
||||
sortthread.NewThreadClient(c),
|
||||
sortthread.NewSortClient(c),
|
||||
extensions.NewListStatusClient(c),
|
||||
}
|
||||
if w.idler != nil {
|
||||
w.idler.SetClient(w.client)
|
||||
c.Updates = w.updates
|
||||
}
|
||||
if w.observer != nil {
|
||||
w.observer.SetClient(w.client)
|
||||
}
|
||||
sort, err := w.client.sort.SupportSort()
|
||||
if err == nil && sort {
|
||||
w.caps.Sort = true
|
||||
w.worker.Debugf("Server Capability found: Sort")
|
||||
}
|
||||
for _, alg := range []sortthread.ThreadAlgorithm{sortthread.References, sortthread.OrderedSubject} {
|
||||
ok, err := w.client.Support(fmt.Sprintf("THREAD=%s", string(alg)))
|
||||
if err == nil && ok {
|
||||
w.threadAlgorithm = alg
|
||||
w.caps.Thread = true
|
||||
w.worker.Debugf("Server Capability found: Thread (algorithm: %s)", string(alg))
|
||||
break
|
||||
}
|
||||
}
|
||||
lStatus, err := w.client.liststatus.SupportListStatus()
|
||||
if err == nil && lStatus {
|
||||
w.liststatus = true
|
||||
w.caps.Extensions = append(w.caps.Extensions, "LIST-STATUS")
|
||||
w.worker.Debugf("Server Capability found: LIST-STATUS")
|
||||
}
|
||||
xgmext, err := w.client.Support("X-GM-EXT-1")
|
||||
if err == nil && xgmext && w.config.useXGMEXT {
|
||||
w.caps.Extensions = append(w.caps.Extensions, "X-GM-EXT-1")
|
||||
w.worker.Debugf("Server Capability found: X-GM-EXT-1")
|
||||
w.worker = middleware.NewGmailWorker(w.worker, w.client.Client)
|
||||
}
|
||||
if err == nil && !xgmext && w.config.useXGMEXT {
|
||||
w.worker.Infof("X-GM-EXT-1 requested, but it is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) handleMessage(msg types.WorkerMessage) error {
|
||||
var reterr error // will be returned at the end, needed to support idle
|
||||
|
||||
// when client is nil allow only certain messages to be handled
|
||||
if w.client == nil {
|
||||
switch msg.(type) {
|
||||
case *types.Connect, *types.Reconnect, *types.Disconnect, *types.Configure:
|
||||
default:
|
||||
return errClientNotReady
|
||||
}
|
||||
}
|
||||
|
||||
// set connection timeout for calls to imap server
|
||||
if w.client != nil {
|
||||
w.client.Timeout = w.config.connection_timeout
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *types.Unsupported:
|
||||
// No-op
|
||||
case *types.Configure:
|
||||
reterr = w.handleConfigure(msg)
|
||||
case *types.Connect:
|
||||
if w.client != nil && w.client.State() == imap.SelectedState {
|
||||
if !w.observer.AutoReconnect() {
|
||||
w.observer.SetAutoReconnect(true)
|
||||
w.observer.EmitIfNotConnected()
|
||||
}
|
||||
reterr = errAlreadyConnected
|
||||
break
|
||||
}
|
||||
|
||||
w.observer.SetAutoReconnect(true)
|
||||
c, err := w.connect()
|
||||
if err != nil {
|
||||
w.observer.EmitIfNotConnected()
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
|
||||
w.newClient(c)
|
||||
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
case *types.Reconnect:
|
||||
if !w.observer.AutoReconnect() {
|
||||
reterr = fmt.Errorf("auto-reconnect is disabled; run connect to enable it")
|
||||
break
|
||||
}
|
||||
c, err := w.connect()
|
||||
if err != nil {
|
||||
errReconnect := w.observer.DelayedReconnect()
|
||||
reterr = errors.Wrap(errReconnect, err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
w.newClient(c)
|
||||
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
case *types.Disconnect:
|
||||
w.observer.SetAutoReconnect(false)
|
||||
w.observer.Stop()
|
||||
|
||||
if w.client == nil || (w.client != nil && w.client.State() != imap.SelectedState) {
|
||||
reterr = errNotConnected
|
||||
break
|
||||
}
|
||||
|
||||
if err := w.client.Logout(); err != nil {
|
||||
w.terminate()
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
case *types.ListDirectories:
|
||||
w.handleListDirectories(msg)
|
||||
case *types.OpenDirectory:
|
||||
w.handleOpenDirectory(msg)
|
||||
case *types.FetchDirectoryContents:
|
||||
w.handleFetchDirectoryContents(msg)
|
||||
case *types.FetchDirectoryThreaded:
|
||||
w.handleDirectoryThreaded(msg)
|
||||
case *types.CreateDirectory:
|
||||
w.handleCreateDirectory(msg)
|
||||
case *types.RemoveDirectory:
|
||||
w.handleRemoveDirectory(msg)
|
||||
case *types.FetchMessageHeaders:
|
||||
w.handleFetchMessageHeaders(msg)
|
||||
case *types.FetchMessageBodyPart:
|
||||
w.handleFetchMessageBodyPart(msg)
|
||||
case *types.FetchFullMessages:
|
||||
w.handleFetchFullMessages(msg)
|
||||
case *types.FetchMessageFlags:
|
||||
w.handleFetchMessageFlags(msg)
|
||||
case *types.DeleteMessages:
|
||||
w.handleDeleteMessages(msg)
|
||||
case *types.FlagMessages:
|
||||
w.handleFlagMessages(msg)
|
||||
case *types.AnsweredMessages:
|
||||
w.handleAnsweredMessages(msg)
|
||||
case *types.CopyMessages:
|
||||
w.handleCopyMessages(msg)
|
||||
case *types.MoveMessages:
|
||||
w.handleMoveMessages(msg)
|
||||
case *types.AppendMessage:
|
||||
w.handleAppendMessage(msg)
|
||||
case *types.SearchDirectory:
|
||||
w.handleSearchDirectory(msg)
|
||||
case *types.CheckMail:
|
||||
w.handleCheckMailMessage(msg)
|
||||
default:
|
||||
reterr = errUnsupported
|
||||
}
|
||||
|
||||
// we don't want idle to timeout, so set timeout to zero
|
||||
if w.client != nil {
|
||||
w.client.Timeout = 0
|
||||
}
|
||||
|
||||
return reterr
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) handleImapUpdate(update client.Update) {
|
||||
w.worker.Tracef("(= %T", update)
|
||||
switch update := update.(type) {
|
||||
case *client.MailboxUpdate:
|
||||
w.worker.PostAction(&types.CheckMail{
|
||||
Directories: []string{update.Mailbox.Name},
|
||||
}, nil)
|
||||
case *client.MessageUpdate:
|
||||
msg := update.Message
|
||||
if msg.Uid == 0 {
|
||||
if uid, found := w.seqMap.Get(msg.SeqNum); !found {
|
||||
w.worker.Errorf("MessageUpdate unknown seqnum: %d", msg.SeqNum)
|
||||
return
|
||||
} else {
|
||||
msg.Uid = uid
|
||||
}
|
||||
}
|
||||
if int(msg.SeqNum) > w.seqMap.Size() {
|
||||
w.seqMap.Put(msg.Uid)
|
||||
}
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Info: &models.MessageInfo{
|
||||
BodyStructure: translateBodyStructure(msg.BodyStructure),
|
||||
Envelope: translateEnvelope(msg.Envelope),
|
||||
Flags: translateImapFlags(msg.Flags),
|
||||
InternalDate: msg.InternalDate,
|
||||
Uid: models.Uint32ToUid(msg.Uid),
|
||||
},
|
||||
}, nil)
|
||||
case *client.ExpungeUpdate:
|
||||
if uid, found := w.seqMap.Pop(update.SeqNum); !found {
|
||||
w.worker.Errorf("ExpungeUpdate unknown seqnum: %d", update.SeqNum)
|
||||
} else {
|
||||
w.worker.PostMessage(&types.MessagesDeleted{
|
||||
Uids: []models.UID{models.Uint32ToUid(uid)},
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) terminate() {
|
||||
if w.observer != nil {
|
||||
w.observer.Stop()
|
||||
w.observer.SetClient(nil)
|
||||
}
|
||||
|
||||
if w.client != nil {
|
||||
w.client.Updates = nil
|
||||
if err := w.client.Terminate(); err != nil {
|
||||
w.worker.Errorf("could not terminate connection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.client = nil
|
||||
w.selected = &imap.MailboxStatus{}
|
||||
|
||||
if w.idler != nil {
|
||||
w.idler.SetClient(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) stopIdler() error {
|
||||
if w.idler == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := w.idler.Stop(); err != nil {
|
||||
w.terminate()
|
||||
w.observer.EmitIfNotConnected()
|
||||
w.worker.Errorf("idler stopped with error:%v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) startIdler() {
|
||||
if w.idler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.idler.Start()
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) Run() {
|
||||
for {
|
||||
select {
|
||||
case msg := <-w.worker.Actions():
|
||||
|
||||
if err := w.stopIdler(); err != nil {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
break
|
||||
}
|
||||
w.worker.Tracef("ready to handle %T", msg)
|
||||
|
||||
msg = w.worker.ProcessAction(msg)
|
||||
|
||||
if err := w.handleMessage(msg); errors.Is(err, errUnsupported) {
|
||||
w.worker.PostMessage(&types.Unsupported{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
} else if err != nil {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
w.startIdler()
|
||||
|
||||
case update := <-w.updates:
|
||||
w.handleImapUpdate(update)
|
||||
|
||||
case <-w.executeIdle:
|
||||
w.idler.Execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) Capabilities() *models.Capabilities {
|
||||
return w.caps
|
||||
}
|
||||
|
||||
func (w *IMAPWorker) PathSeparator() string {
|
||||
if w.delimiter == "" {
|
||||
return "/"
|
||||
}
|
||||
return w.delimiter
|
||||
}
|
||||
Vendored
+45
@@ -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 == "" || id == "" {
|
||||
return ""
|
||||
}
|
||||
name := string(id)
|
||||
sub := name[len(name)-2:]
|
||||
return path.Join(c.blobsDir, sub, name)
|
||||
}
|
||||
Vendored
+109
@@ -0,0 +1,109 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
"github.com/syndtr/goleveldb/leveldb/util"
|
||||
)
|
||||
|
||||
type JMAPCache struct {
|
||||
mem map[string][]byte
|
||||
file *leveldb.DB
|
||||
blobsDir string
|
||||
}
|
||||
|
||||
func NewJMAPCache(state, blobs bool, accountName string) *JMAPCache {
|
||||
c := new(JMAPCache)
|
||||
cacheDir := xdg.CachePath()
|
||||
if state && cacheDir != "" {
|
||||
var err error
|
||||
dir := path.Join(cacheDir, "aerc", accountName, "state")
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
c.file, err = leveldb.OpenFile(dir, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to open goleveldb: %s", err)
|
||||
c.mem = make(map[string][]byte)
|
||||
}
|
||||
} else {
|
||||
c.mem = make(map[string][]byte)
|
||||
}
|
||||
if blobs && cacheDir != "" {
|
||||
c.blobsDir = path.Join(cacheDir, "aerc", accountName, "blobs")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func (c *JMAPCache) purge(prefix string) error {
|
||||
switch {
|
||||
case c.file != nil:
|
||||
txn, err := c.file.OpenTransaction()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iter := txn.NewIterator(util.BytesPrefix([]byte(prefix)), nil)
|
||||
for iter.Next() {
|
||||
err = txn.Delete(iter.Key(), nil)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
if err != nil {
|
||||
txn.Discard()
|
||||
return err
|
||||
}
|
||||
return txn.Commit()
|
||||
case c.mem != nil:
|
||||
for key := range c.mem {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(c.mem, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
panic("jmap cache with no backend")
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
)
|
||||
|
||||
func (c *JMAPCache) HasEmail(id jmap.ID) bool {
|
||||
_, err := c.get(emailKey(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetEmail(id jmap.ID) (*email.Email, error) {
|
||||
buf, err := c.get(emailKey(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(emailKey(id), buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteEmail(id jmap.ID) error {
|
||||
return c.delete(emailKey(id))
|
||||
}
|
||||
|
||||
func emailKey(id jmap.ID) string {
|
||||
return "email/" + string(id)
|
||||
}
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
)
|
||||
|
||||
type FolderContents struct {
|
||||
MailboxID jmap.ID
|
||||
QueryState string
|
||||
Filter *types.SearchCriteria
|
||||
Sort []*types.SortCriterion
|
||||
MessageIDs []jmap.ID
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) {
|
||||
key := folderContentsKey(mailboxId)
|
||||
buf, err := c.get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := new(FolderContents)
|
||||
err = unmarshal(buf, m)
|
||||
if err != nil {
|
||||
log.Debugf("cache format has changed, purging foldercontents")
|
||||
if e := c.purge("foldercontents/"); e != nil {
|
||||
log.Errorf("foldercontents cache purge: %s", e)
|
||||
}
|
||||
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 *types.SearchCriteria, sort []*types.SortCriterion,
|
||||
) bool {
|
||||
return f.QueryState == "" ||
|
||||
!reflect.DeepEqual(f.Sort, sort) ||
|
||||
!reflect.DeepEqual(f.Filter, filter)
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
|
||||
)
|
||||
|
||||
type jmapObject interface {
|
||||
*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)
|
||||
}
|
||||
Vendored
+35
@@ -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)
|
||||
}
|
||||
Vendored
+32
@@ -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"
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"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 = json.Unmarshal(buf, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutSession(s *jmap.Session) error {
|
||||
buf, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(sessionKey, buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteSession() error {
|
||||
return c.delete(sessionKey)
|
||||
}
|
||||
|
||||
const sessionKey = "session"
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
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))
|
||||
}
|
||||
|
||||
func (c *JMAPCache) GetThreadState() (string, error) {
|
||||
buf, err := c.get(threadStateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func (c *JMAPCache) PutThreadState(state string) error {
|
||||
return c.put(threadStateKey, []byte(state))
|
||||
}
|
||||
|
||||
const (
|
||||
mailboxStateKey = "state/mailbox"
|
||||
emailStateKey = "state/email"
|
||||
threadStateKey = "state/thread"
|
||||
)
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
)
|
||||
|
||||
func (c *JMAPCache) GetThread(id jmap.ID) ([]jmap.ID, error) {
|
||||
buf, err := c.get(threadKey(id))
|
||||
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) PutThread(id jmap.ID, list []jmap.ID) error {
|
||||
buf, err := marshal(&IDList{IDs: list})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.put(threadKey(id), buf)
|
||||
}
|
||||
|
||||
func (c *JMAPCache) DeleteThread(id jmap.ID) error {
|
||||
return c.delete(mailboxKey(id))
|
||||
}
|
||||
|
||||
func threadKey(id jmap.ID) string {
|
||||
return "thread/" + string(id)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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 {
|
||||
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.cache = cache.NewJMAPCache(
|
||||
w.config.cacheState, w.config.cacheBlobs, msg.Config.Name)
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(val string) bool {
|
||||
switch strings.ToLower(val) {
|
||||
case "1", "t", "true", "yes", "y", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"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 {
|
||||
w.client = &jmap.Client{SessionEndpoint: w.config.endpoint}
|
||||
|
||||
if w.config.oauth {
|
||||
pass, _ := w.config.user.Password()
|
||||
w.client.WithAccessToken(pass)
|
||||
} else {
|
||||
user := w.config.user.Username()
|
||||
pass, _ := w.config.user.Password()
|
||||
w.client.WithBasicAuth(user, pass)
|
||||
}
|
||||
|
||||
if session, err := w.cache.GetSession(); err == nil {
|
||||
w.client.Session = session
|
||||
}
|
||||
if w.client.Session == nil {
|
||||
if err := w.UpdateSession(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
go w.monitorChanges()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) AccountId() jmap.ID {
|
||||
switch {
|
||||
case w.client == nil:
|
||||
fallthrough
|
||||
case w.client.Session == nil:
|
||||
fallthrough
|
||||
case w.client.Session.PrimaryAccounts == nil:
|
||||
return ""
|
||||
default:
|
||||
return w.client.Session.PrimaryAccounts[mail.URI]
|
||||
}
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) UpdateSession() error {
|
||||
if err := w.client.Authenticate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.cache.PutSession(w.client.Session); err != nil {
|
||||
w.w.Warnf("PutSession: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) GetIdentities() error {
|
||||
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< %s", seq, err)
|
||||
// Try to update session in case an endpoint changed
|
||||
err := w.UpdateSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And try again if we succeeded
|
||||
resp, err = w.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if resp.SessionState != w.client.Session.State {
|
||||
if err := w.UpdateSession(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
w.w.Debugf("<%d< done", seq)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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)
|
||||
|
||||
// If we can't get the cached mailbox state, at worst, we will just
|
||||
// query information we might already know
|
||||
cachedMailboxState, err := w.cache.GetMailboxState()
|
||||
if err != nil {
|
||||
w.w.Warnf("GetMailboxState: %s", err)
|
||||
}
|
||||
|
||||
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 cachedMailboxState == "" || 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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
if contents.NeedsRefresh(msg.Filter, msg.SortCriteria) {
|
||||
var req jmap.Request
|
||||
|
||||
req.Invoke(&email.Query{
|
||||
Account: w.AccountId(),
|
||||
Filter: w.translateSearch(w.selectedMbox, msg.Filter),
|
||||
Sort: translateSort(msg.SortCriteria),
|
||||
})
|
||||
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 = msg.SortCriteria
|
||||
contents.Filter = msg.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([]models.UID, 0, len(contents.MessageIDs))
|
||||
for _, id := range contents.MessageIDs {
|
||||
uids = append(uids, models.UID(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
|
||||
|
||||
req.Invoke(&email.Query{
|
||||
Account: w.AccountId(),
|
||||
Filter: w.translateSearch(w.selectedMbox, msg.Criteria),
|
||||
})
|
||||
|
||||
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 []models.UID
|
||||
for _, id := range r.IDs {
|
||||
uids = append(uids, models.UID(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 id, ok := w.dir2mbox[msg.Directory]; ok {
|
||||
// directory already exists
|
||||
mbox, err := w.cache.GetMailbox(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
|
||||
return errNoop
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"headers",
|
||||
"messageId",
|
||||
"inReplyTo",
|
||||
"references",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"replyTo",
|
||||
"subject",
|
||||
"bodyStructure",
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleFetchMessageHeaders(msg *types.FetchMessageHeaders) error {
|
||||
emailIdsToFetch := make([]jmap.ID, 0, len(msg.Uids))
|
||||
currentEmails := make([]*email.Email, 0, len(msg.Uids))
|
||||
for _, uid := range msg.Uids {
|
||||
jid := jmap.ID(uid)
|
||||
m, err := w.cache.GetEmail(jid)
|
||||
if err != nil {
|
||||
// Message wasn't in cache; fetch it
|
||||
emailIdsToFetch = append(emailIdsToFetch, jid)
|
||||
continue
|
||||
}
|
||||
currentEmails = append(currentEmails, m)
|
||||
// Get the UI updated immediately
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: w.translateMsgInfo(m),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
if len(emailIdsToFetch) > 0 {
|
||||
var req jmap.Request
|
||||
|
||||
req.Invoke(&email.Get{
|
||||
Account: w.AccountId(),
|
||||
IDs: emailIdsToFetch,
|
||||
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:
|
||||
if err = w.cache.PutEmailState(r.State); err != nil {
|
||||
w.w.Warnf("PutEmailState: %s", err)
|
||||
}
|
||||
currentEmails = append(currentEmails, r.List...)
|
||||
case *jmap.MethodError:
|
||||
return wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var threadsToFetch []jmap.ID
|
||||
for _, eml := range currentEmails {
|
||||
thread, err := w.cache.GetThread(eml.ThreadID)
|
||||
if err != nil {
|
||||
threadsToFetch = append(threadsToFetch, eml.ThreadID)
|
||||
continue
|
||||
}
|
||||
for _, id := range thread {
|
||||
m, err := w.cache.GetEmail(id)
|
||||
if err != nil {
|
||||
// This should never happen. If we have the
|
||||
// thread in cache, we will have fetched it
|
||||
// already or updated it from the update loop
|
||||
w.w.Warnf("Email ID %s from Thread %s not in cache", id, eml.ThreadID)
|
||||
continue
|
||||
}
|
||||
currentEmails = append(currentEmails, m)
|
||||
// Get the UI updated immediately
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: w.translateMsgInfo(m),
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
threadEmails, err := w.fetchEntireThreads(threadsToFetch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range threadEmails {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) handleFetchMessageBodyPart(msg *types.FetchMessageBodyPart) error {
|
||||
mail, err := w.cache.GetEmail(jmap.ID(msg.Uid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bug: unknown message id %s: %w", msg.Uid, 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 {
|
||||
w.w.Warnf("charset.Reader: %v", err)
|
||||
} else {
|
||||
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 {
|
||||
mail, err := w.cache.GetEmail(jmap.ID(uid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bug: unknown message id %s: %w", uid, 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)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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: models.UID(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
|
||||
}
|
||||
if flags.Has(models.DraftFlag) {
|
||||
kw["$draft"] = 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
|
||||
case "$draft":
|
||||
f |= models.DraftFlag
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/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"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/thread"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) monitorChanges() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
emailUpdated := ""
|
||||
emailCreated := ""
|
||||
if emailState != "" && newState["Email"] != emailState {
|
||||
callID := req.Invoke(&email.Changes{
|
||||
Account: w.AccountId(),
|
||||
SinceState: emailState,
|
||||
})
|
||||
emailUpdated = req.Invoke(&email.Get{
|
||||
Account: w.AccountId(),
|
||||
Properties: headersProperties,
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Email/changes",
|
||||
Path: "/updated",
|
||||
},
|
||||
})
|
||||
|
||||
emailCreated = req.Invoke(&email.Get{
|
||||
Account: w.AccountId(),
|
||||
Properties: headersProperties,
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Email/changes",
|
||||
Path: "/created",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
threadState, err := w.cache.GetThreadState()
|
||||
if err != nil {
|
||||
w.w.Debugf("GetThreadState: %s", err)
|
||||
}
|
||||
if threadState != "" && newState["Thread"] != threadState {
|
||||
callID := req.Invoke(&thread.Changes{
|
||||
Account: w.AccountId(),
|
||||
SinceState: threadState,
|
||||
})
|
||||
req.Invoke(&thread.Get{
|
||||
Account: w.AccountId(),
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Thread/changes",
|
||||
Path: "/created",
|
||||
},
|
||||
})
|
||||
req.Invoke(&thread.Get{
|
||||
Account: w.AccountId(),
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: callID,
|
||||
Name: "Thread/changes",
|
||||
Path: "/updated",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.Calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var changedMboxIds []jmap.ID
|
||||
var labelsChanged bool
|
||||
// threadEmails are email IDs from threads which changed or were
|
||||
// created
|
||||
var threadEmails []jmap.ID
|
||||
|
||||
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 *thread.ChangesResponse:
|
||||
for _, id := range r.Destroyed {
|
||||
err = w.cache.DeleteThread(id)
|
||||
if err != nil {
|
||||
w.w.Warnf("DeleteThread: %s", err)
|
||||
}
|
||||
}
|
||||
err = w.cache.PutThreadState(r.NewState)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutThreadState: %s", err)
|
||||
}
|
||||
|
||||
case *thread.GetResponse:
|
||||
for _, thread := range r.List {
|
||||
err = w.cache.PutThread(thread.ID, thread.EmailIDs)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutThread: %s", err)
|
||||
}
|
||||
// We keep the list of all emails and check in a
|
||||
// subsequent request which ones we need to
|
||||
// fetch
|
||||
threadEmails = append(threadEmails, thread.EmailIDs...)
|
||||
}
|
||||
err = w.cache.PutThreadState(r.State)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutThreadState: %s", err)
|
||||
}
|
||||
|
||||
case *email.GetResponse:
|
||||
switch inv.CallID {
|
||||
case emailUpdated:
|
||||
for _, m := range r.List {
|
||||
err = w.cache.PutEmail(m.ID, m)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutEmail: %s", err)
|
||||
}
|
||||
// Send an updated message info if this
|
||||
// is part of our selected mailbox
|
||||
if m.MailboxIDs[w.selectedMbox] {
|
||||
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 emailCreated:
|
||||
for _, m := range r.List {
|
||||
err = w.cache.PutEmail(m.ID, m)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutEmail: %s", err)
|
||||
}
|
||||
info := w.translateMsgInfo(m)
|
||||
// Set recent on created messages so we
|
||||
// get a notification
|
||||
info.Flags |= models.RecentFlag
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Info: info,
|
||||
}, 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))
|
||||
}
|
||||
}
|
||||
|
||||
var updatedMboxes []jmap.ID
|
||||
for _, id := range changedMboxIds {
|
||||
mbox := mboxes[id]
|
||||
if mbox.Role == mailbox.RoleArchive && w.config.useLabels {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
|
||||
updatedMboxes = append(updatedMboxes, id)
|
||||
} 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 w.refreshQueriesAndThreads(updatedMboxes, threadEmails)
|
||||
}
|
||||
|
||||
// refreshQueriesAndThreads updates the cached query for any mailbox which was updated
|
||||
func (w *JMAPWorker) refreshQueriesAndThreads(
|
||||
updatedMboxes []jmap.ID,
|
||||
threadEmails []jmap.ID,
|
||||
) error {
|
||||
if len(updatedMboxes) == 0 && len(threadEmails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var req jmap.Request
|
||||
queryChangesCalls := make(map[string]jmap.ID)
|
||||
folderContents := make(map[jmap.ID]*cache.FolderContents)
|
||||
|
||||
for _, id := range updatedMboxes {
|
||||
contents, err := w.cache.GetFolderContents(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
callID := req.Invoke(&email.QueryChanges{
|
||||
Account: w.AccountId(),
|
||||
Filter: w.translateSearch(id, contents.Filter),
|
||||
Sort: translateSort(contents.Sort),
|
||||
SinceQueryState: contents.QueryState,
|
||||
})
|
||||
queryChangesCalls[callID] = id
|
||||
folderContents[id] = contents
|
||||
}
|
||||
|
||||
emailsToFetch := []jmap.ID{}
|
||||
for _, id := range threadEmails {
|
||||
if w.cache.HasEmail(id) {
|
||||
continue
|
||||
}
|
||||
emailsToFetch = append(emailsToFetch, id)
|
||||
}
|
||||
|
||||
req.Invoke(&email.Get{
|
||||
Account: w.AccountId(),
|
||||
Properties: headersProperties,
|
||||
IDs: emailsToFetch,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
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([]models.UID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
uids = append(uids, models.UID(id))
|
||||
}
|
||||
w.w.PostMessage(&types.DirectoryContents{
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
case *email.GetResponse:
|
||||
for _, m := range r.List {
|
||||
err = w.cache.PutEmail(m.ID, m)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutEmail: %s", err)
|
||||
}
|
||||
// Send an updated message info if this
|
||||
// is part of our selected mailbox
|
||||
if m.MailboxIDs[w.selectedMbox] {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"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) translateSearch(
|
||||
mbox jmap.ID, criteria *types.SearchCriteria,
|
||||
) email.Filter {
|
||||
cond := new(email.FilterCondition)
|
||||
|
||||
if mbox == "" {
|
||||
// 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)
|
||||
}
|
||||
cond.InMailboxOtherThan = mboxes
|
||||
} else {
|
||||
cond.InMailbox = mbox
|
||||
}
|
||||
if criteria == nil {
|
||||
return cond
|
||||
}
|
||||
|
||||
// dates
|
||||
if !criteria.StartDate.IsZero() {
|
||||
cond.After = &criteria.StartDate
|
||||
}
|
||||
if !criteria.EndDate.IsZero() {
|
||||
cond.Before = &criteria.EndDate
|
||||
}
|
||||
|
||||
// general search terms
|
||||
terms := strings.Join(criteria.Terms, " ")
|
||||
switch {
|
||||
case criteria.SearchAll:
|
||||
cond.Text = terms
|
||||
case criteria.SearchBody:
|
||||
cond.Body = terms
|
||||
default:
|
||||
cond.Subject = terms
|
||||
}
|
||||
|
||||
filter := &email.FilterOperator{Operator: jmap.OperatorAND}
|
||||
filter.Conditions = append(filter.Conditions, cond)
|
||||
|
||||
// keywords/flags
|
||||
for kw := range flagsToKeywords(criteria.WithFlags) {
|
||||
filter.Conditions = append(filter.Conditions,
|
||||
&email.FilterCondition{HasKeyword: kw})
|
||||
}
|
||||
for kw := range flagsToKeywords(criteria.WithoutFlags) {
|
||||
filter.Conditions = append(filter.Conditions,
|
||||
&email.FilterCondition{NotKeyword: kw})
|
||||
}
|
||||
|
||||
// recipients
|
||||
addrs := &email.FilterOperator{
|
||||
Operator: jmap.OperatorOR,
|
||||
}
|
||||
for _, from := range criteria.From {
|
||||
addrs.Conditions = append(addrs.Conditions,
|
||||
&email.FilterCondition{From: from})
|
||||
}
|
||||
for _, to := range criteria.To {
|
||||
addrs.Conditions = append(addrs.Conditions,
|
||||
&email.FilterCondition{To: to})
|
||||
}
|
||||
for _, cc := range criteria.Cc {
|
||||
addrs.Conditions = append(addrs.Conditions,
|
||||
&email.FilterCondition{Cc: cc})
|
||||
}
|
||||
if len(addrs.Conditions) > 0 {
|
||||
filter.Conditions = append(filter.Conditions, addrs)
|
||||
}
|
||||
|
||||
// specific headers
|
||||
headers := &email.FilterOperator{
|
||||
Operator: jmap.OperatorAND,
|
||||
}
|
||||
for h, values := range criteria.Headers {
|
||||
for _, v := range values {
|
||||
headers.Conditions = append(headers.Conditions,
|
||||
&email.FilterCondition{Header: []string{h, v}})
|
||||
}
|
||||
}
|
||||
if len(headers.Conditions) > 0 {
|
||||
filter.Conditions = append(filter.Conditions, headers)
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/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.From)
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
from := &emailsubmission.Address{Email: msg.From.Address}
|
||||
var rcpts []*emailsubmission.Address
|
||||
for _, address := range msg.Rcpts {
|
||||
rcpts = append(rcpts, &emailsubmission.Address{
|
||||
Email: address.Address,
|
||||
})
|
||||
}
|
||||
envelope := &emailsubmission.Envelope{MailFrom: from, RcptTo: rcpts}
|
||||
onSuccess := jmap.Patch{
|
||||
"keywords/$draft": nil,
|
||||
w.rolePatch(mailbox.RoleSent): true,
|
||||
w.rolePatch(mailbox.RoleDrafts): nil,
|
||||
}
|
||||
for _, dir := range msg.CopyTo {
|
||||
mbox, ok := w.dir2mbox[dir]
|
||||
if ok && mbox != w.roles[mailbox.RoleSent] {
|
||||
onSuccess[w.mboxPatch(mbox)] = true
|
||||
}
|
||||
}
|
||||
// Create the submission
|
||||
req.Invoke(&emailsubmission.Set{
|
||||
Account: w.AccountId(),
|
||||
Create: map[jmap.ID]*emailsubmission.EmailSubmission{
|
||||
"sub": {
|
||||
IdentityID: identity,
|
||||
EmailID: "#aerc",
|
||||
Envelope: envelope,
|
||||
},
|
||||
},
|
||||
OnSuccessUpdateEmail: map[jmap.ID]jmap.Patch{
|
||||
"#sub": onSuccess,
|
||||
},
|
||||
})
|
||||
|
||||
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(from *mail.Address) (jmap.ID, error) {
|
||||
if len(w.identities) == 0 {
|
||||
if err := w.GetIdentities(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
name, domain, _ := strings.Cut(from.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)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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 []models.UID, flags models.Flags, enable bool) error {
|
||||
var req jmap.Request
|
||||
patches := make(map[jmap.ID]jmap.Patch)
|
||||
|
||||
for _, uid := range uids {
|
||||
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(uid)] = patch
|
||||
}
|
||||
|
||||
req.Invoke(&email.Set{
|
||||
Account: w.AccountId(),
|
||||
Update: patches,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = checkNotUpdated(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If we didn't get an update error, all methods succeeded. We can
|
||||
// update the cache and UI now. We don't update the email state so that
|
||||
// we still grab an updated set from the update channel
|
||||
for _, uid := range uids {
|
||||
jid := jmap.ID(uid)
|
||||
m, err := w.cache.GetEmail(jid)
|
||||
if err != nil {
|
||||
// We'll get this from the update channel
|
||||
continue
|
||||
}
|
||||
if enable {
|
||||
for kw := range flagsToKeywords(flags) {
|
||||
m.Keywords[kw] = true
|
||||
}
|
||||
} else {
|
||||
for kw := range flagsToKeywords(flags) {
|
||||
delete(m.Keywords, kw)
|
||||
}
|
||||
}
|
||||
err = w.cache.PutEmail(jid, m)
|
||||
if err != nil {
|
||||
w.w.Warnf("PutEmail: %s", err)
|
||||
}
|
||||
// Get the UI updated immediately
|
||||
w.w.PostMessage(&types.MessageInfo{
|
||||
Info: w.translateMsgInfo(m),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *JMAPWorker) moveCopy(uids []models.UID, 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
|
||||
mail, err := w.cache.GetEmail(jmap.ID(uid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("bug: unknown message id %s: %w", uid, 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(uid)] = 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("unknown label: %q", a)
|
||||
}
|
||||
patch[w.mboxPatch(mboxId)] = true
|
||||
}
|
||||
for _, r := range msg.Remove {
|
||||
mboxId, ok := w.dir2mbox[r]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown label: %q", r)
|
||||
}
|
||||
patch[w.mboxPatch(mboxId)] = nil
|
||||
}
|
||||
|
||||
patches := make(map[jmap.ID]jmap.Patch)
|
||||
|
||||
for _, uid := range msg.Uids {
|
||||
patches[jmap.ID(uid)] = 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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rockorager/go-jmap"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/email"
|
||||
"git.sr.ht/~rockorager/go-jmap/mail/thread"
|
||||
)
|
||||
|
||||
func (w *JMAPWorker) fetchEntireThreads(threads []jmap.ID) ([]*email.Email, error) {
|
||||
var req jmap.Request
|
||||
|
||||
if len(threads) == 0 {
|
||||
return []*email.Email{}, nil
|
||||
}
|
||||
|
||||
threadGetId := req.Invoke(&thread.Get{
|
||||
Account: w.AccountId(),
|
||||
IDs: threads,
|
||||
})
|
||||
|
||||
// Opportunistically fetch all emails in this thread. We could wait for
|
||||
// the result, check which ones we don't have, then fetch only those.
|
||||
// However we can do this all in a single request which ends up being
|
||||
// faster than two requests for most contexts
|
||||
req.Invoke(&email.Get{
|
||||
Account: w.AccountId(),
|
||||
ReferenceIDs: &jmap.ResultReference{
|
||||
ResultOf: threadGetId,
|
||||
Name: "Thread/get",
|
||||
Path: "/list/*/emailIds",
|
||||
},
|
||||
Properties: headersProperties,
|
||||
})
|
||||
|
||||
resp, err := w.Do(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
emailsToReturn := make([]*email.Email, 0)
|
||||
for _, inv := range resp.Responses {
|
||||
switch r := inv.Args.(type) {
|
||||
case *thread.GetResponse:
|
||||
if err = w.cache.PutThreadState(r.State); err != nil {
|
||||
w.w.Warnf("PutThreadState: %s", err)
|
||||
}
|
||||
for _, thread := range r.List {
|
||||
if err = w.cache.PutThread(thread.ID, thread.EmailIDs); err != nil {
|
||||
w.w.Warnf("PutThread: %s", err)
|
||||
}
|
||||
}
|
||||
case *email.GetResponse:
|
||||
emailsToReturn = append(emailsToReturn, r.List...)
|
||||
if err = w.cache.PutEmailState(r.State); err != nil {
|
||||
w.w.Warnf("PutEmailState: %s", err)
|
||||
}
|
||||
case *jmap.MethodError:
|
||||
return nil, wrapMethodError(r)
|
||||
}
|
||||
}
|
||||
|
||||
return emailsToReturn, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"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 (
|
||||
errNoop error = errors.New("noop")
|
||||
errUnsupported error = 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
|
||||
|
||||
selectedMbox jmap.ID
|
||||
dir2mbox map[string]jmap.ID
|
||||
mbox2dir map[jmap.ID]string
|
||||
roles map[mailbox.Role]jmap.ID
|
||||
identities map[string]*identity.Identity
|
||||
|
||||
changes chan jmap.TypeState
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewJMAPWorker(worker *types.Worker) (types.Backend, error) {
|
||||
return &JMAPWorker{
|
||||
w: worker,
|
||||
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.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, errNoop):
|
||||
// Operation did not have any effect.
|
||||
// Do *NOT* send a Done message.
|
||||
break
|
||||
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: // err == nil
|
||||
// Operation is finished.
|
||||
// Send a Done message.
|
||||
w.w.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
func ParseFolderMap(r io.Reader) (map[string]string, []string, error) {
|
||||
cfg, err := ini.Load(r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
sec, err := cfg.GetSection("")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
order := sec.KeyStrings()
|
||||
|
||||
for _, k := range order {
|
||||
v, err := sec.GetKey(k)
|
||||
switch {
|
||||
case v.String() == "":
|
||||
return nil, nil, fmt.Errorf("no value for key '%s'", k)
|
||||
case err != nil:
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return sec.KeysHash(), order, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package lib_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
)
|
||||
|
||||
func TestFolderMap(t *testing.T) {
|
||||
text := `#this is comment
|
||||
|
||||
Sent = [Gmail]/Sent
|
||||
|
||||
# a comment between entries
|
||||
Spam=[Gmail]/Spam # this is comment after the values
|
||||
`
|
||||
fmap, order, err := lib.ParseFolderMap(strings.NewReader(text))
|
||||
if err != nil {
|
||||
t.Errorf("parsing failed: %v", err)
|
||||
}
|
||||
|
||||
want_map := map[string]string{
|
||||
"Sent": "[Gmail]/Sent",
|
||||
"Spam": "[Gmail]/Spam",
|
||||
}
|
||||
want_order := []string{"Sent", "Spam"}
|
||||
|
||||
if !reflect.DeepEqual(order, want_order) {
|
||||
t.Errorf("order is not correct; want: %v, got: %v",
|
||||
want_order, order)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(fmap, want_map) {
|
||||
t.Errorf("map is not correct; want: %v, got: %v",
|
||||
want_map, fmap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderMap_ExpectFails(t *testing.T) {
|
||||
tests := []string{
|
||||
`key = `,
|
||||
` = value`,
|
||||
` = `,
|
||||
`key = #value`,
|
||||
}
|
||||
for _, text := range tests {
|
||||
_, _, err := lib.ParseFolderMap(strings.NewReader(text))
|
||||
if err == nil {
|
||||
t.Errorf("expected to fail, but it did not: %v", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
// LimitHeaders returns a new Header with the specified headers included or
|
||||
// excluded
|
||||
func LimitHeaders(hdr *mail.Header, fields []string, exclude bool) *mail.Header {
|
||||
fieldMap := make(map[string]struct{}, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldMap[strings.ToLower(f)] = struct{}{}
|
||||
}
|
||||
nh := &mail.Header{}
|
||||
curFields := hdr.Fields()
|
||||
for curFields.Next() {
|
||||
key := strings.ToLower(curFields.Key())
|
||||
_, present := fieldMap[key]
|
||||
// XOR exclude and present. When they are equal, it means we
|
||||
// should not add the header to the new header struct
|
||||
if exclude == present {
|
||||
continue
|
||||
}
|
||||
nh.Add(key, curFields.Value())
|
||||
}
|
||||
return nh
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-maildir"
|
||||
)
|
||||
|
||||
type MaildirStore struct {
|
||||
root string
|
||||
maildirpp bool // whether to use Maildir++ directory layout
|
||||
}
|
||||
|
||||
func NewMaildirStore(root string, maildirpp bool) (*MaildirStore, error) {
|
||||
f, err := os.Open(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
s, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.IsDir() {
|
||||
return nil, fmt.Errorf("Given maildir '%s' not a directory", root)
|
||||
}
|
||||
return &MaildirStore{
|
||||
root: root, maildirpp: maildirpp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MaildirStore) FolderMap() (map[string]maildir.Dir, error) {
|
||||
folders := make(map[string]maildir.Dir)
|
||||
if s.maildirpp {
|
||||
// In Maildir++ layout, INBOX is the root folder
|
||||
folders["INBOX"] = maildir.Dir(s.root)
|
||||
}
|
||||
err := filepath.Walk(s.root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("Invalid path '%s': error: %w", path, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip maildir's default directories
|
||||
n := info.Name()
|
||||
if n == "new" || n == "tmp" || n == "cur" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Get the relative path from the parent directory
|
||||
dirPath, err := filepath.Rel(s.root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the parent directory
|
||||
if dirPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drop dirs that lack {new,tmp,cur} subdirs
|
||||
for _, sub := range []string{"new", "tmp", "cur"} {
|
||||
if _, err := os.Stat(filepath.Join(path, sub)); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if s.maildirpp {
|
||||
// In Maildir++ layout, mailboxes are stored in a single directory
|
||||
// and prefixed with a dot, and subfolders are separated by dots.
|
||||
if !strings.HasPrefix(dirPath, ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
dirPath = strings.TrimPrefix(dirPath, ".")
|
||||
dirPath = strings.ReplaceAll(dirPath, ".", "/")
|
||||
folders[dirPath] = maildir.Dir(path)
|
||||
|
||||
// Since all mailboxes are stored in a single directory, don't
|
||||
// recurse into subdirectories
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
folders[dirPath] = maildir.Dir(path)
|
||||
return nil
|
||||
})
|
||||
return folders, err
|
||||
}
|
||||
|
||||
// Folder returns a maildir.Dir with the specified name inside the Store
|
||||
func (s *MaildirStore) Dir(name string) maildir.Dir {
|
||||
if s.maildirpp {
|
||||
// Use Maildir++ layout
|
||||
if name == "INBOX" {
|
||||
return maildir.Dir(s.root)
|
||||
}
|
||||
return maildir.Dir(filepath.Join(s.root, "."+strings.ReplaceAll(name, "/", ".")))
|
||||
}
|
||||
return maildir.Dir(filepath.Join(s.root, name))
|
||||
}
|
||||
|
||||
// uidReg matches filename encoded UIDs in maildirs synched with mbsync or
|
||||
// OfflineIMAP
|
||||
var uidReg = regexp.MustCompile(`,U=\d+`)
|
||||
|
||||
func StripUIDFromMessageFilename(basename string) string {
|
||||
return uidReg.ReplaceAllString(basename, "")
|
||||
}
|
||||
|
||||
var MaildirToFlag = map[maildir.Flag]models.Flags{
|
||||
maildir.FlagReplied: models.AnsweredFlag,
|
||||
maildir.FlagSeen: models.SeenFlag,
|
||||
maildir.FlagTrashed: models.DeletedFlag,
|
||||
maildir.FlagFlagged: models.FlaggedFlag,
|
||||
maildir.FlagDraft: models.DraftFlag,
|
||||
maildir.FlagPassed: models.ForwardedFlag,
|
||||
}
|
||||
|
||||
var FlagToMaildir = map[models.Flags]maildir.Flag{
|
||||
models.AnsweredFlag: maildir.FlagReplied,
|
||||
models.SeenFlag: maildir.FlagSeen,
|
||||
models.DeletedFlag: maildir.FlagTrashed,
|
||||
models.FlaggedFlag: maildir.FlagFlagged,
|
||||
models.DraftFlag: maildir.FlagDraft,
|
||||
models.ForwardedFlag: maildir.FlagPassed,
|
||||
}
|
||||
|
||||
func FromMaildirFlags(maildirFlags []maildir.Flag) models.Flags {
|
||||
var flags models.Flags
|
||||
for _, maildirFlag := range maildirFlags {
|
||||
if flag, ok := MaildirToFlag[maildirFlag]; ok {
|
||||
flags |= flag
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func ToMaildirFlags(flags models.Flags) []maildir.Flag {
|
||||
var maildirFlags []maildir.Flag
|
||||
for flag, maildirFlag := range FlagToMaildir {
|
||||
if flags.Has(flag) {
|
||||
maildirFlags = append(maildirFlags, maildirFlag)
|
||||
}
|
||||
}
|
||||
return maildirFlags
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
func Search(messages []rfc822.RawMessage, criteria *types.SearchCriteria) ([]models.UID, error) {
|
||||
criteria.PrepareHeader()
|
||||
requiredParts := GetRequiredParts(criteria)
|
||||
|
||||
var matchedUids []models.UID
|
||||
for _, m := range messages {
|
||||
success, err := SearchMessage(m, criteria, requiredParts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if success {
|
||||
matchedUids = append(matchedUids, m.UID())
|
||||
}
|
||||
}
|
||||
|
||||
return matchedUids, nil
|
||||
}
|
||||
|
||||
// searchMessage executes the search criteria for the given RawMessage,
|
||||
// returns true if search succeeded
|
||||
func SearchMessage(message rfc822.RawMessage, criteria *types.SearchCriteria,
|
||||
parts MsgParts,
|
||||
) (bool, error) {
|
||||
if criteria == nil {
|
||||
return true, nil
|
||||
}
|
||||
// setup parts of the message to use in the search
|
||||
// this is so that we try to minimise reading unnecessary parts
|
||||
var (
|
||||
flags models.Flags
|
||||
info *models.MessageInfo
|
||||
text string
|
||||
err error
|
||||
)
|
||||
|
||||
if parts&FLAGS > 0 {
|
||||
flags, err = message.ModelFlags()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
info, err = rfc822.MessageInfo(message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch {
|
||||
case parts&BODY > 0:
|
||||
path := lib.FindFirstNonMultipart(info.BodyStructure, nil)
|
||||
reader, err := message.NewReader()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer reader.Close()
|
||||
msg, err := rfc822.ReadMessage(reader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
part, err := rfc822.FetchEntityPartReader(msg, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
bytes, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
text = string(bytes)
|
||||
case parts&ALL > 0:
|
||||
reader, err := message.NewReader()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer reader.Close()
|
||||
bytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
text = string(bytes)
|
||||
default:
|
||||
text = info.Envelope.Subject
|
||||
}
|
||||
|
||||
// now search through the criteria
|
||||
// implicit AND at the moment so fail fast
|
||||
if criteria.Headers != nil {
|
||||
for k, v := range criteria.Headers {
|
||||
headerValue := info.RFC822Headers.Get(k)
|
||||
for _, text := range v {
|
||||
if !containsSmartCase(headerValue, text) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args := opt.LexArgs(strings.Join(criteria.Terms, " "))
|
||||
for _, searchTerm := range args.Args() {
|
||||
if !containsSmartCase(text, searchTerm) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if criteria.WithFlags != 0 {
|
||||
if !flags.Has(criteria.WithFlags) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if criteria.WithoutFlags != 0 {
|
||||
if flags.Has(criteria.WithoutFlags) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if parts&DATE > 0 {
|
||||
if date, err := info.RFC822Headers.Date(); err != nil {
|
||||
log.Errorf("Failed to get date from header: %v", err)
|
||||
} else {
|
||||
if !criteria.StartDate.IsZero() {
|
||||
if date.Before(criteria.StartDate) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if !criteria.EndDate.IsZero() {
|
||||
if date.After(criteria.EndDate) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// containsSmartCase is a smarter version of strings.Contains for searching.
|
||||
// Is case-insensitive unless substr contains an upper case character
|
||||
func containsSmartCase(s string, substr string) bool {
|
||||
if hasUpper(substr) {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||
}
|
||||
|
||||
func hasUpper(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The parts of a message, kind of
|
||||
type MsgParts int
|
||||
|
||||
const NONE MsgParts = 0
|
||||
const (
|
||||
FLAGS MsgParts = 1 << iota
|
||||
HEADER
|
||||
DATE
|
||||
BODY
|
||||
ALL
|
||||
)
|
||||
|
||||
// Returns a bitmask of the parts of the message required to be loaded for the
|
||||
// given criteria
|
||||
func GetRequiredParts(criteria *types.SearchCriteria) MsgParts {
|
||||
required := NONE
|
||||
if criteria == nil {
|
||||
return required
|
||||
}
|
||||
if len(criteria.Headers) > 0 {
|
||||
required |= HEADER
|
||||
}
|
||||
if !criteria.StartDate.IsZero() || !criteria.EndDate.IsZero() {
|
||||
required |= DATE
|
||||
}
|
||||
if criteria.SearchBody {
|
||||
required |= BODY
|
||||
}
|
||||
if criteria.SearchAll {
|
||||
required |= ALL
|
||||
}
|
||||
if criteria.WithFlags != 0 {
|
||||
required |= FLAGS
|
||||
}
|
||||
if criteria.WithoutFlags != 0 {
|
||||
required |= FLAGS
|
||||
}
|
||||
|
||||
return required
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FileSize returns the size of the file specified by name
|
||||
func FileSize(name string) (uint32, error) {
|
||||
fileInfo, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to obtain fileinfo: %w", err)
|
||||
}
|
||||
return uint32(fileInfo.Size()), nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
func Sort(messageInfos []*models.MessageInfo,
|
||||
criteria []*types.SortCriterion,
|
||||
) ([]models.UID, error) {
|
||||
// loop through in reverse to ensure we sort by non-primary fields first
|
||||
for i := len(criteria) - 1; i >= 0; i-- {
|
||||
criterion := criteria[i]
|
||||
switch criterion.Field {
|
||||
case types.SortArrival:
|
||||
sortSlice(criterion, messageInfos, func(i, j int) bool {
|
||||
return messageInfos[i].InternalDate.Before(messageInfos[j].InternalDate)
|
||||
})
|
||||
case types.SortCc:
|
||||
sortAddresses(messageInfos, criterion,
|
||||
func(msgInfo *models.MessageInfo) []*mail.Address {
|
||||
return msgInfo.Envelope.Cc
|
||||
})
|
||||
case types.SortDate:
|
||||
sortSlice(criterion, messageInfos, func(i, j int) bool {
|
||||
return messageInfos[i].Envelope.Date.Before(messageInfos[j].Envelope.Date)
|
||||
})
|
||||
case types.SortFrom:
|
||||
sortAddresses(messageInfos, criterion,
|
||||
func(msgInfo *models.MessageInfo) []*mail.Address {
|
||||
return msgInfo.Envelope.From
|
||||
})
|
||||
case types.SortRead:
|
||||
sortFlags(messageInfos, criterion, models.SeenFlag)
|
||||
case types.SortFlagged:
|
||||
sortFlags(messageInfos, criterion, models.FlaggedFlag)
|
||||
case types.SortSize:
|
||||
sortSlice(criterion, messageInfos, func(i, j int) bool {
|
||||
return messageInfos[i].Size < messageInfos[j].Size
|
||||
})
|
||||
case types.SortSubject:
|
||||
sortStrings(messageInfos, criterion,
|
||||
func(msgInfo *models.MessageInfo) string {
|
||||
subject := strings.ToLower(msgInfo.Envelope.Subject)
|
||||
subject = strings.TrimPrefix(subject, "re: ")
|
||||
return strings.TrimPrefix(subject, "fwd: ")
|
||||
})
|
||||
case types.SortTo:
|
||||
sortAddresses(messageInfos, criterion,
|
||||
func(msgInfo *models.MessageInfo) []*mail.Address {
|
||||
return msgInfo.Envelope.To
|
||||
})
|
||||
}
|
||||
}
|
||||
var uids []models.UID
|
||||
// copy in reverse as msgList displays backwards
|
||||
for i := len(messageInfos) - 1; i >= 0; i-- {
|
||||
uids = append(uids, messageInfos[i].Uid)
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
func sortAddresses(messageInfos []*models.MessageInfo, criterion *types.SortCriterion,
|
||||
getValue func(*models.MessageInfo) []*mail.Address,
|
||||
) {
|
||||
sortSlice(criterion, messageInfos, func(i, j int) bool {
|
||||
addressI, addressJ := getValue(messageInfos[i]), getValue(messageInfos[j])
|
||||
var firstI, firstJ *mail.Address
|
||||
if len(addressI) > 0 {
|
||||
firstI = addressI[0]
|
||||
}
|
||||
if len(addressJ) > 0 {
|
||||
firstJ = addressJ[0]
|
||||
}
|
||||
if firstI != nil && firstJ != nil {
|
||||
getName := func(addr *mail.Address) string {
|
||||
if addr.Name != "" {
|
||||
return addr.Name
|
||||
} else {
|
||||
return addr.Address
|
||||
}
|
||||
}
|
||||
return getName(firstI) < getName(firstJ)
|
||||
} else {
|
||||
return firstI != nil && firstJ == nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sortFlags(messageInfos []*models.MessageInfo, criterion *types.SortCriterion,
|
||||
testFlag models.Flags,
|
||||
) {
|
||||
var slice []*boolStore
|
||||
for _, msgInfo := range messageInfos {
|
||||
slice = append(slice, &boolStore{
|
||||
Value: msgInfo.Flags.Has(testFlag),
|
||||
MsgInfo: msgInfo,
|
||||
})
|
||||
}
|
||||
sortSlice(criterion, slice, func(i, j int) bool {
|
||||
valI, valJ := slice[i].Value, slice[j].Value
|
||||
return valI && !valJ
|
||||
})
|
||||
for i := 0; i < len(messageInfos); i++ {
|
||||
messageInfos[i] = slice[i].MsgInfo
|
||||
}
|
||||
}
|
||||
|
||||
func sortStrings(messageInfos []*models.MessageInfo, criterion *types.SortCriterion,
|
||||
getValue func(*models.MessageInfo) string,
|
||||
) {
|
||||
var slice []*lexiStore
|
||||
for _, msgInfo := range messageInfos {
|
||||
slice = append(slice, &lexiStore{
|
||||
Value: getValue(msgInfo),
|
||||
MsgInfo: msgInfo,
|
||||
})
|
||||
}
|
||||
sortSlice(criterion, slice, func(i, j int) bool {
|
||||
return slice[i].Value < slice[j].Value
|
||||
})
|
||||
for i := 0; i < len(messageInfos); i++ {
|
||||
messageInfos[i] = slice[i].MsgInfo
|
||||
}
|
||||
}
|
||||
|
||||
type lexiStore struct {
|
||||
Value string
|
||||
MsgInfo *models.MessageInfo
|
||||
}
|
||||
|
||||
type boolStore struct {
|
||||
Value bool
|
||||
MsgInfo *models.MessageInfo
|
||||
}
|
||||
|
||||
func sortSlice(criterion *types.SortCriterion, slice interface{}, less func(i, j int) bool) {
|
||||
if criterion.Reverse {
|
||||
sort.SliceStable(slice, func(i, j int) bool {
|
||||
return less(j, i)
|
||||
})
|
||||
} else {
|
||||
sort.SliceStable(slice, less)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package maildir
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/emersion/go-maildir"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
)
|
||||
|
||||
// A Container is a directory which contains other directories which adhere to
|
||||
// the Maildir spec
|
||||
type Container struct {
|
||||
Store *lib.MaildirStore
|
||||
recentUIDS map[models.UID]struct{} // used to set the recent flag
|
||||
}
|
||||
|
||||
// NewContainer creates a new container at the specified directory
|
||||
func NewContainer(dir string, maildirpp bool) (*Container, error) {
|
||||
store, err := lib.NewMaildirStore(dir, maildirpp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Container{
|
||||
Store: store,
|
||||
recentUIDS: make(map[models.UID]struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncNewMail adds emails from new to cur, tracking them
|
||||
func (c *Container) SyncNewMail(dir maildir.Dir) error {
|
||||
keys, err := dir.Unseen()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
c.recentUIDS[models.UID(key)] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenDirectory opens an existing maildir in the container by name, moves new
|
||||
// messages into cur, and registers the new keys in the UIDStore.
|
||||
func (c *Container) OpenDirectory(name string) (maildir.Dir, error) {
|
||||
dir := c.Store.Dir(name)
|
||||
if err := c.SyncNewMail(dir); err != nil {
|
||||
return dir, err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// IsRecent returns if a uid has the Recent flag set
|
||||
func (c *Container) IsRecent(uid models.UID) bool {
|
||||
_, ok := c.recentUIDS[uid]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ClearRecentFlag removes the Recent flag from the message with the given uid
|
||||
func (c *Container) ClearRecentFlag(uid models.UID) {
|
||||
delete(c.recentUIDS, uid)
|
||||
}
|
||||
|
||||
// UIDs fetches the unique message identifiers for the maildir
|
||||
func (c *Container) UIDs(d maildir.Dir) ([]models.UID, error) {
|
||||
keys, err := d.Keys()
|
||||
if err != nil && len(keys) == 0 {
|
||||
return nil, fmt.Errorf("could not get keys for %s: %w", d, err)
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf("could not get all keys for %s: %s", d, err.Error())
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var uids []models.UID
|
||||
for _, key := range keys {
|
||||
uids = append(uids, models.UID(key))
|
||||
}
|
||||
return uids, err
|
||||
}
|
||||
|
||||
// Message returns a Message struct for the given UID and maildir
|
||||
func (c *Container) Message(d maildir.Dir, uid models.UID) (*Message, error) {
|
||||
return &Message{
|
||||
dir: d,
|
||||
uid: uid,
|
||||
key: string(uid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteAll deletes a set of messages by UID and returns the subset of UIDs
|
||||
// which were successfully deleted, stopping upon the first error.
|
||||
func (c *Container) DeleteAll(d maildir.Dir, uids []models.UID) ([]models.UID, error) {
|
||||
var success []models.UID
|
||||
for _, uid := range uids {
|
||||
msg, err := c.Message(d, uid)
|
||||
if err != nil {
|
||||
return success, err
|
||||
}
|
||||
if err := msg.Remove(); err != nil {
|
||||
return success, err
|
||||
}
|
||||
success = append(success, uid)
|
||||
}
|
||||
return success, nil
|
||||
}
|
||||
|
||||
func (c *Container) CopyAll(
|
||||
dest maildir.Dir, src maildir.Dir, uids []models.UID,
|
||||
) error {
|
||||
for _, uid := range uids {
|
||||
if err := c.copyMessage(dest, src, uid); err != nil {
|
||||
return fmt.Errorf("could not copy message %s: %w", uid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Container) copyMessage(
|
||||
dest maildir.Dir, src maildir.Dir, uid models.UID,
|
||||
) error {
|
||||
_, err := src.Copy(dest, string(uid))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Container) MoveAll(dest maildir.Dir, src maildir.Dir, uids []models.UID) ([]models.UID, error) {
|
||||
var success []models.UID
|
||||
for _, uid := range uids {
|
||||
if err := c.moveMessage(dest, src, uid); err != nil {
|
||||
return success, fmt.Errorf("could not move message %s: %w", uid, err)
|
||||
}
|
||||
success = append(success, uid)
|
||||
}
|
||||
return success, nil
|
||||
}
|
||||
|
||||
func (c *Container) moveMessage(dest maildir.Dir, src maildir.Dir, uid models.UID) error {
|
||||
path, err := src.Filename(string(uid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not find path for message id %s: %w", uid, err)
|
||||
}
|
||||
// Remove encoded UID information from the key to prevent sync issues
|
||||
name := lib.StripUIDFromMessageFilename(filepath.Base(path))
|
||||
destPath := filepath.Join(string(dest), "cur", name)
|
||||
return os.Rename(path, destPath)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package maildir
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/emersion/go-maildir"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
)
|
||||
|
||||
// A Message is an individual email inside of a maildir.Dir.
|
||||
type Message struct {
|
||||
dir maildir.Dir
|
||||
uid models.UID
|
||||
key string
|
||||
}
|
||||
|
||||
// NewReader reads a message into memory and returns an io.Reader for it.
|
||||
func (m Message) NewReader() (io.ReadCloser, error) {
|
||||
return m.dir.Open(m.key)
|
||||
}
|
||||
|
||||
// Flags fetches the set of flags currently applied to the message.
|
||||
func (m Message) Flags() ([]maildir.Flag, error) {
|
||||
return m.dir.Flags(m.key)
|
||||
}
|
||||
|
||||
// ModelFlags fetches the set of models.flags currently applied to the message.
|
||||
func (m Message) ModelFlags() (models.Flags, error) {
|
||||
flags, err := m.dir.Flags(m.key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return lib.FromMaildirFlags(flags), nil
|
||||
}
|
||||
|
||||
// SetFlags replaces the message's flags with a new set.
|
||||
func (m Message) SetFlags(flags []maildir.Flag) error {
|
||||
return m.dir.SetFlags(m.key, flags)
|
||||
}
|
||||
|
||||
// SetOneFlag enables or disables a single message flag on the message.
|
||||
func (m Message) SetOneFlag(flag maildir.Flag, enable bool) error {
|
||||
flags, err := m.Flags()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read previous flags: %w", err)
|
||||
}
|
||||
if enable {
|
||||
flags = append(flags, flag)
|
||||
return m.SetFlags(flags)
|
||||
}
|
||||
var newFlags []maildir.Flag
|
||||
for _, oldFlag := range flags {
|
||||
if oldFlag != flag {
|
||||
newFlags = append(newFlags, oldFlag)
|
||||
}
|
||||
}
|
||||
return m.SetFlags(newFlags)
|
||||
}
|
||||
|
||||
// MarkForwarded either adds or removes the maildir.FlagForwarded flag
|
||||
// from the message.
|
||||
func (m Message) MarkForwarded(forwarded bool) error {
|
||||
return m.SetOneFlag(maildir.FlagPassed, forwarded)
|
||||
}
|
||||
|
||||
// MarkReplied either adds or removes the maildir.FlagReplied flag from the
|
||||
// message.
|
||||
func (m Message) MarkReplied(answered bool) error {
|
||||
return m.SetOneFlag(maildir.FlagReplied, answered)
|
||||
}
|
||||
|
||||
// Remove deletes the email immediately.
|
||||
func (m Message) Remove() error {
|
||||
return m.dir.Remove(m.key)
|
||||
}
|
||||
|
||||
// MessageInfo populates a models.MessageInfo struct for the message.
|
||||
func (m Message) MessageInfo() (*models.MessageInfo, error) {
|
||||
info, err := rfc822.MessageInfo(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.Size, err = m.Size()
|
||||
if err != nil {
|
||||
// don't care if size retrieval fails
|
||||
log.Debugf("message size: %v", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m Message) Size() (uint32, error) {
|
||||
name, err := m.dir.Filename(m.key)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get filename: %w", err)
|
||||
}
|
||||
size, err := lib.FileSize(name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get filesize: %w", err)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// MessageHeaders populates a models.MessageInfo struct for the message with
|
||||
// minimal information, used for sorting and threading.
|
||||
func (m Message) MessageHeaders() (*models.MessageInfo, error) {
|
||||
info, err := rfc822.MessageHeaders(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.Size, err = m.Size()
|
||||
if err != nil {
|
||||
// don't care if size retrieval fails
|
||||
log.Debugf("message size failed: %v", err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// NewBodyPartReader creates a new io.Reader for the requested body part(s) of
|
||||
// the message.
|
||||
func (m Message) NewBodyPartReader(requestedParts []int) (io.Reader, error) {
|
||||
f, err := m.dir.Open(m.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
msg, err := rfc822.ReadMessage(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read message: %w", err)
|
||||
}
|
||||
return rfc822.FetchEntityPartReader(msg, requestedParts)
|
||||
}
|
||||
|
||||
func (m Message) UID() models.UID {
|
||||
return m.uid
|
||||
}
|
||||
|
||||
func (m Message) Labels() ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package maildir
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (w *Worker) search(ctx context.Context, criteria *types.SearchCriteria) ([]models.UID, error) {
|
||||
criteria.PrepareHeader()
|
||||
requiredParts := lib.GetRequiredParts(criteria)
|
||||
w.worker.Debugf("Required parts bitmask for search: %b", requiredParts)
|
||||
|
||||
keys, err := w.c.UIDs(*w.selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matchedUids []models.UID
|
||||
mu := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
// Hard limit at 2x CPU cores
|
||||
max := runtime.NumCPU() * 2
|
||||
limit := make(chan struct{}, max)
|
||||
for _, key := range keys {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, context.Canceled
|
||||
default:
|
||||
limit <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(key models.UID) {
|
||||
defer log.PanicHandler()
|
||||
defer wg.Done()
|
||||
success, err := w.searchKey(key, criteria, requiredParts)
|
||||
if err != nil {
|
||||
// don't return early so that we can still get some results
|
||||
w.worker.Errorf("Failed to search key %d: %v", key, err)
|
||||
} else if success {
|
||||
mu.Lock()
|
||||
matchedUids = append(matchedUids, key)
|
||||
mu.Unlock()
|
||||
}
|
||||
<-limit
|
||||
}(key)
|
||||
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
return matchedUids, nil
|
||||
}
|
||||
|
||||
// Execute the search criteria for the given key, returns true if search succeeded
|
||||
func (w *Worker) searchKey(key models.UID, criteria *types.SearchCriteria,
|
||||
parts lib.MsgParts,
|
||||
) (bool, error) {
|
||||
message, err := w.c.Message(*w.selected, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return lib.SearchMessage(message, criteria, parts)
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
package maildir
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-maildir"
|
||||
|
||||
aercLib "git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/iterator"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/watchers"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/handlers"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
"git.sr.ht/~rjarry/aerc/worker/middleware"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
handlers.RegisterWorkerFactory("maildir", NewWorker)
|
||||
handlers.RegisterWorkerFactory("maildirpp", NewMaildirppWorker)
|
||||
}
|
||||
|
||||
var errUnsupported = fmt.Errorf("unsupported command")
|
||||
|
||||
// A Worker handles interfacing between aerc's UI and a group of maildirs.
|
||||
type Worker struct {
|
||||
c *Container
|
||||
selected *maildir.Dir
|
||||
selectedName string
|
||||
selectedInfo *models.DirectoryInfo
|
||||
worker types.WorkerInteractor
|
||||
watcher watchers.FSWatcher
|
||||
watcherDebounce *time.Timer
|
||||
fsEvents chan struct{}
|
||||
currentSortCriteria []*types.SortCriterion
|
||||
maildirpp bool // whether to use Maildir++ directory layout
|
||||
capabilities *models.Capabilities
|
||||
headers []string
|
||||
headersExclude []string
|
||||
}
|
||||
|
||||
// NewWorker creates a new maildir worker with the provided worker.
|
||||
func NewWorker(worker *types.Worker) (types.Backend, error) {
|
||||
watch, err := watchers.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create file system watcher: %w", err)
|
||||
}
|
||||
return &Worker{
|
||||
capabilities: &models.Capabilities{
|
||||
Sort: true,
|
||||
Thread: true,
|
||||
},
|
||||
worker: worker,
|
||||
watcher: watch,
|
||||
fsEvents: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewMaildirppWorker creates a new Maildir++ worker with the provided worker.
|
||||
func NewMaildirppWorker(worker *types.Worker) (types.Backend, error) {
|
||||
watch, err := watchers.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not create file system watcher: %w", err)
|
||||
}
|
||||
return &Worker{
|
||||
capabilities: &models.Capabilities{
|
||||
Sort: true,
|
||||
Thread: true,
|
||||
},
|
||||
worker: worker,
|
||||
watcher: watch,
|
||||
maildirpp: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the worker's message handling loop.
|
||||
func (w *Worker) Run() {
|
||||
for {
|
||||
select {
|
||||
case action := <-w.worker.Actions():
|
||||
w.handleAction(action)
|
||||
case <-w.watcher.Events():
|
||||
if w.watcherDebounce != nil {
|
||||
w.watcherDebounce.Stop()
|
||||
}
|
||||
// Debounce FS changes
|
||||
w.watcherDebounce = time.AfterFunc(50*time.Millisecond, func() {
|
||||
defer log.PanicHandler()
|
||||
w.fsEvents <- struct{}{}
|
||||
})
|
||||
case <-w.fsEvents:
|
||||
w.handleFSEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Capabilities() *models.Capabilities {
|
||||
return w.capabilities
|
||||
}
|
||||
|
||||
func (w *Worker) PathSeparator() string {
|
||||
return string(os.PathSeparator)
|
||||
}
|
||||
|
||||
func (w *Worker) handleAction(action types.WorkerMessage) {
|
||||
msg := w.worker.ProcessAction(action)
|
||||
switch msg := msg.(type) {
|
||||
// Explicitly handle all asynchronous actions. Async actions are
|
||||
// responsible for posting their own Done message
|
||||
case *types.CheckMail:
|
||||
go w.handleCheckMail(msg)
|
||||
default:
|
||||
// Default handling, will be performed synchronously
|
||||
err := w.handleMessage(msg)
|
||||
switch {
|
||||
case errors.Is(err, errUnsupported):
|
||||
w.worker.PostMessage(&types.Unsupported{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
case errors.Is(err, context.Canceled):
|
||||
w.worker.PostMessage(&types.Cancelled{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
case err != nil:
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
default:
|
||||
w.done(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) handleFSEvent() {
|
||||
// if there's not a selected directory to rescan, ignore
|
||||
if w.selected == nil {
|
||||
return
|
||||
}
|
||||
err := w.c.SyncNewMail(*w.selected)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not move new to cur : %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.selectedInfo = w.getDirectoryInfo(w.selectedName)
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.selectedInfo,
|
||||
Refetch: true,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (w *Worker) done(msg types.WorkerMessage) {
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
|
||||
func (w *Worker) err(msg types.WorkerMessage, err error) {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func splitMaildirFile(name string) (uniq string, flags []maildir.Flag, err error) {
|
||||
i := strings.LastIndexByte(name, ':')
|
||||
if i < 0 {
|
||||
return "", nil, &maildir.MailfileError{Name: name}
|
||||
}
|
||||
info := name[i+1:]
|
||||
uniq = name[:i]
|
||||
if len(info) < 2 {
|
||||
return "", nil, &maildir.FlagError{Info: info, Experimental: false}
|
||||
}
|
||||
if info[1] != ',' || info[0] != '2' {
|
||||
return "", nil, &maildir.FlagError{Info: info, Experimental: false}
|
||||
}
|
||||
if info[0] == '1' {
|
||||
return "", nil, &maildir.FlagError{Info: info, Experimental: true}
|
||||
}
|
||||
flags = []maildir.Flag(info[2:])
|
||||
sort.Slice(flags, func(i, j int) bool { return info[i] < info[j] })
|
||||
return uniq, flags, nil
|
||||
}
|
||||
|
||||
func dirFiles(name string) ([]string, error) {
|
||||
dir, err := os.Open(filepath.Join(name, "cur"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Readdirnames(-1)
|
||||
}
|
||||
|
||||
func (w *Worker) getDirectoryInfo(name string) *models.DirectoryInfo {
|
||||
dirInfo := &models.DirectoryInfo{
|
||||
Name: name,
|
||||
// total messages
|
||||
Exists: 0,
|
||||
// new messages since mailbox was last opened
|
||||
Recent: 0,
|
||||
// total unread
|
||||
Unseen: 0,
|
||||
}
|
||||
|
||||
dir := w.c.Store.Dir(name)
|
||||
var keyFlags map[string][]maildir.Flag
|
||||
files, err := dirFiles(string(dir))
|
||||
if err == nil {
|
||||
keyFlags = make(map[string][]maildir.Flag, len(files))
|
||||
for _, v := range files {
|
||||
key, flags, err := splitMaildirFile(v)
|
||||
if err != nil {
|
||||
w.worker.Errorf("%q: error parsing flags (%q): %v", v, key, err)
|
||||
continue
|
||||
}
|
||||
keyFlags[key] = flags
|
||||
}
|
||||
} else {
|
||||
w.worker.Tracef("disabled flags cache: %q: %v", dir, err)
|
||||
}
|
||||
|
||||
uids, err := w.c.UIDs(dir)
|
||||
if err != nil && len(uids) == 0 {
|
||||
w.worker.Errorf("could not get uids: %v", err)
|
||||
return dirInfo
|
||||
}
|
||||
|
||||
dirInfo.Exists = len(uids)
|
||||
for _, uid := range uids {
|
||||
message, err := w.c.Message(dir, uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message: %v", err)
|
||||
continue
|
||||
}
|
||||
var flags []maildir.Flag
|
||||
if keyFlags != nil {
|
||||
ok := false
|
||||
flags, ok = keyFlags[message.key]
|
||||
if !ok {
|
||||
w.worker.Tracef("message (key=%q uid=%d) not found in map cache",
|
||||
message.key, message.uid)
|
||||
flags, err = message.Flags()
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get flags: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flags, err = message.Flags()
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get flags: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
seen := false
|
||||
for _, flag := range flags {
|
||||
if flag == maildir.FlagSeen {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
dirInfo.Unseen++
|
||||
}
|
||||
if w.c.IsRecent(uid) {
|
||||
dirInfo.Recent++
|
||||
}
|
||||
}
|
||||
return dirInfo
|
||||
}
|
||||
|
||||
func (w *Worker) handleMessage(msg types.WorkerMessage) error {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Unsupported:
|
||||
// No-op
|
||||
case *types.Configure:
|
||||
return w.handleConfigure(msg)
|
||||
case *types.Connect:
|
||||
return w.handleConnect(msg)
|
||||
case *types.ListDirectories:
|
||||
return w.handleListDirectories(msg)
|
||||
case *types.OpenDirectory:
|
||||
return w.handleOpenDirectory(msg)
|
||||
case *types.FetchDirectoryContents:
|
||||
return w.handleFetchDirectoryContents(msg)
|
||||
case *types.FetchDirectoryThreaded:
|
||||
return w.handleFetchDirectoryThreaded(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.DeleteMessages:
|
||||
return w.handleDeleteMessages(msg)
|
||||
case *types.FlagMessages:
|
||||
return w.handleFlagMessages(msg)
|
||||
case *types.AnsweredMessages:
|
||||
return w.handleAnsweredMessages(msg)
|
||||
case *types.ForwardedMessages:
|
||||
return w.handleForwardedMessages(msg)
|
||||
case *types.CopyMessages:
|
||||
return w.handleCopyMessages(msg)
|
||||
case *types.MoveMessages:
|
||||
return w.handleMoveMessages(msg)
|
||||
case *types.AppendMessage:
|
||||
return w.handleAppendMessage(msg)
|
||||
case *types.SearchDirectory:
|
||||
return w.handleSearchDirectory(msg)
|
||||
}
|
||||
return errUnsupported
|
||||
}
|
||||
|
||||
func (w *Worker) handleConfigure(msg *types.Configure) error {
|
||||
u, err := url.Parse(msg.Config.Source)
|
||||
if err != nil {
|
||||
w.worker.Errorf("error configuring maildir worker: %v", err)
|
||||
return err
|
||||
}
|
||||
dir := u.Path
|
||||
if u.Host == "~" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve home directory: %w", err)
|
||||
}
|
||||
dir = filepath.Join(home, u.Path)
|
||||
}
|
||||
if len(dir) == 0 {
|
||||
return fmt.Errorf("could not resolve maildir from URL '%s'", msg.Config.Source)
|
||||
}
|
||||
c, err := NewContainer(dir, w.maildirpp)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not configure maildir: %s", dir)
|
||||
return err
|
||||
}
|
||||
w.c = c
|
||||
err = w.watcher.Configure(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.headers = msg.Config.Headers
|
||||
w.headersExclude = msg.Config.HeadersExclude
|
||||
w.worker.Debugf("configured base maildir: %s", dir)
|
||||
|
||||
if name, ok := msg.Config.Params["folder-map"]; ok {
|
||||
file := xdg.ExpandHome(name)
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fmap, order, err := lib.ParseFolderMap(bufio.NewReader(f))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.worker = middleware.NewFolderMapper(w.worker, fmap, order)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleConnect(msg *types.Connect) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleListDirectories(msg *types.ListDirectories) error {
|
||||
// TODO If handleConfigure has returned error, w.c is nil.
|
||||
// It could be better if we skip directory listing completely
|
||||
// when configure fails.
|
||||
if w.c == nil {
|
||||
return errors.New("Incorrect maildir directory")
|
||||
}
|
||||
dirs, err := w.c.Store.FolderMap()
|
||||
if err != nil {
|
||||
w.worker.Errorf("failed listing directories: %v", err)
|
||||
return err
|
||||
}
|
||||
for name := range dirs {
|
||||
w.worker.PostMessage(&types.Directory{
|
||||
Message: types.RespondTo(msg),
|
||||
Dir: &models.Directory{
|
||||
Name: name,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(name),
|
||||
}, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleOpenDirectory(msg *types.OpenDirectory) error {
|
||||
w.worker.Debugf("opening %s", msg.Directory)
|
||||
|
||||
// open the directory
|
||||
dir, err := w.c.OpenDirectory(msg.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove existing watch paths
|
||||
if w.selected != nil {
|
||||
prevDir := filepath.Join(string(*w.selected), "new")
|
||||
if err := w.watcher.Remove(prevDir); err != nil {
|
||||
return fmt.Errorf("could not unwatch previous directory: %w", err)
|
||||
}
|
||||
prevDir = filepath.Join(string(*w.selected), "cur")
|
||||
if err := w.watcher.Remove(prevDir); err != nil {
|
||||
return fmt.Errorf("could not unwatch previous directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.selected = &dir
|
||||
w.selectedName = msg.Directory
|
||||
|
||||
// add watch paths
|
||||
newDir := filepath.Join(string(*w.selected), "new")
|
||||
if err := w.watcher.Add(newDir); err != nil {
|
||||
return fmt.Errorf("could not add watch to directory: %w", err)
|
||||
}
|
||||
newDir = filepath.Join(string(*w.selected), "cur")
|
||||
if err := w.watcher.Add(newDir); err != nil {
|
||||
return fmt.Errorf("could not add watch to directory: %w", err)
|
||||
}
|
||||
|
||||
if err := dir.Clean(); err != nil {
|
||||
return fmt.Errorf("could not clean directory: %w", err)
|
||||
}
|
||||
|
||||
info := &types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(msg.Directory),
|
||||
}
|
||||
w.selectedInfo = info.Info
|
||||
w.worker.PostMessage(info, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleFetchDirectoryContents(
|
||||
msg *types.FetchDirectoryContents,
|
||||
) error {
|
||||
var (
|
||||
uids []models.UID
|
||||
err error
|
||||
)
|
||||
if msg.Filter != nil {
|
||||
uids, err = w.search(msg.Context, msg.Filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
uids, err = w.c.UIDs(*w.selected)
|
||||
if err != nil && len(uids) == 0 {
|
||||
w.worker.Errorf("failed scanning uids: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Error: fmt.Errorf("could not get all uids for %s: %w", *w.selected, err),
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
sortedUids, err := w.sort(msg.Context, uids, msg.SortCriteria)
|
||||
if err != nil {
|
||||
w.worker.Errorf("failed sorting directory: %v", err)
|
||||
return err
|
||||
}
|
||||
w.currentSortCriteria = msg.SortCriteria
|
||||
w.worker.PostMessage(&types.DirectoryContents{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: sortedUids,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) sort(ctx context.Context, uids []models.UID, criteria []*types.SortCriterion) ([]models.UID, error) {
|
||||
if len(criteria) == 0 {
|
||||
// At least sort by uid, parallel searching can create random
|
||||
// order
|
||||
sort.Slice(uids, func(i int, j int) bool {
|
||||
return uids[i] < uids[j]
|
||||
})
|
||||
return uids, nil
|
||||
}
|
||||
var msgInfos []*models.MessageInfo
|
||||
mu := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
// Hard limit at 2x CPU cores
|
||||
max := runtime.NumCPU() * 2
|
||||
limit := make(chan struct{}, max)
|
||||
for _, uid := range uids {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, context.Canceled
|
||||
default:
|
||||
limit <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(uid models.UID) {
|
||||
defer log.PanicHandler()
|
||||
defer wg.Done()
|
||||
info, err := w.msgHeadersFromUid(uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message info: %v", err)
|
||||
<-limit
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
msgInfos = append(msgInfos, info)
|
||||
mu.Unlock()
|
||||
<-limit
|
||||
}(uid)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
sortedUids, err := lib.Sort(msgInfos, criteria)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not sort the messages: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
return sortedUids, nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleFetchDirectoryThreaded(
|
||||
msg *types.FetchDirectoryThreaded,
|
||||
) error {
|
||||
var (
|
||||
uids []models.UID
|
||||
err error
|
||||
)
|
||||
if msg.Filter != nil {
|
||||
uids, err = w.search(msg.Context, msg.Filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
uids, err = w.c.UIDs(*w.selected)
|
||||
if err != nil && len(uids) == 0 {
|
||||
w.worker.Errorf("failed scanning uids: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
threads, err := w.threads(msg.Context, uids, msg.SortCriteria)
|
||||
if err != nil {
|
||||
w.worker.Errorf("failed sorting directory: %v", err)
|
||||
return err
|
||||
}
|
||||
w.currentSortCriteria = msg.SortCriteria
|
||||
w.worker.PostMessage(&types.DirectoryThreaded{
|
||||
Message: types.RespondTo(msg),
|
||||
Threads: threads,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) threads(ctx context.Context, uids []models.UID,
|
||||
criteria []*types.SortCriterion,
|
||||
) ([]*types.Thread, error) {
|
||||
builder := aercLib.NewThreadBuilder(iterator.NewFactory(false), false)
|
||||
msgInfos := make([]*models.MessageInfo, 0, len(uids))
|
||||
mu := sync.Mutex{}
|
||||
wg := sync.WaitGroup{}
|
||||
max := runtime.NumCPU() * 2
|
||||
limit := make(chan struct{}, max)
|
||||
for _, uid := range uids {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, context.Canceled
|
||||
default:
|
||||
limit <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(uid models.UID) {
|
||||
defer log.PanicHandler()
|
||||
defer wg.Done()
|
||||
info, err := w.msgHeadersFromUid(uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message info: %v", err)
|
||||
<-limit
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
builder.Update(info)
|
||||
msgInfos = append(msgInfos, info)
|
||||
mu.Unlock()
|
||||
<-limit
|
||||
}(uid)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
var err error
|
||||
switch {
|
||||
case len(criteria) == 0:
|
||||
sort.Slice(uids, func(i int, j int) bool {
|
||||
return uids[i] < uids[j]
|
||||
})
|
||||
default:
|
||||
uids, err = lib.Sort(msgInfos, criteria)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not sort the messages: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
threads := builder.Threads(uids, false, false)
|
||||
return threads, nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleCreateDirectory(msg *types.CreateDirectory) error {
|
||||
dir := w.c.Store.Dir(msg.Directory)
|
||||
if err := dir.Init(); err != nil {
|
||||
w.worker.Errorf("could not create directory %s: %v",
|
||||
msg.Directory, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleRemoveDirectory(msg *types.RemoveDirectory) error {
|
||||
dir := w.c.Store.Dir(msg.Directory)
|
||||
if err := os.RemoveAll(string(dir)); err != nil {
|
||||
w.worker.Errorf("could not remove directory %s: %v",
|
||||
msg.Directory, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleFetchMessageHeaders(
|
||||
msg *types.FetchMessageHeaders,
|
||||
) error {
|
||||
for _, uid := range msg.Uids {
|
||||
info, err := w.msgInfoFromUid(uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message info: %v", err)
|
||||
log.Errorf("could not get message info: %v", err)
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Info: &models.MessageInfo{
|
||||
Envelope: &models.Envelope{},
|
||||
Flags: models.SeenFlag,
|
||||
Uid: uid,
|
||||
Error: err,
|
||||
},
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(w.headersExclude) > 0:
|
||||
info.RFC822Headers = lib.LimitHeaders(info.RFC822Headers, w.headersExclude, true)
|
||||
case len(w.headers) > 0:
|
||||
info.RFC822Headers = lib.LimitHeaders(info.RFC822Headers, w.headers, false)
|
||||
}
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: info,
|
||||
}, nil)
|
||||
w.c.ClearRecentFlag(uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleFetchMessageBodyPart(
|
||||
msg *types.FetchMessageBodyPart,
|
||||
) error {
|
||||
// get reader
|
||||
m, err := w.c.Message(*w.selected, msg.Uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message %d: %v", msg.Uid, err)
|
||||
return err
|
||||
}
|
||||
r, err := m.NewBodyPartReader(msg.Part)
|
||||
if err != nil {
|
||||
w.worker.Errorf(
|
||||
"could not get body part reader for message=%d, parts=%#v: %w",
|
||||
msg.Uid, msg.Part, err)
|
||||
return err
|
||||
}
|
||||
w.worker.PostMessage(&types.MessageBodyPart{
|
||||
Message: types.RespondTo(msg),
|
||||
Part: &models.MessageBodyPart{
|
||||
Reader: r,
|
||||
Uid: msg.Uid,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleFetchFullMessages(msg *types.FetchFullMessages) error {
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.c.Message(*w.selected, uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message %d: %v", uid, err)
|
||||
return err
|
||||
}
|
||||
r, err := m.NewReader()
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message reader: %v", err)
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.worker.PostMessage(&types.FullMessage{
|
||||
Message: types.RespondTo(msg),
|
||||
Content: &models.FullMessage{
|
||||
Uid: uid,
|
||||
Reader: bytes.NewReader(b),
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleDeleteMessages(msg *types.DeleteMessages) error {
|
||||
deleted, err := w.c.DeleteAll(*w.selected, msg.Uids)
|
||||
if len(deleted) > 0 {
|
||||
w.worker.PostMessage(&types.MessagesDeleted{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: deleted,
|
||||
}, nil)
|
||||
}
|
||||
if err != nil {
|
||||
w.worker.Errorf("failed removing messages: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleAnsweredMessages(msg *types.AnsweredMessages) error {
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.c.Message(*w.selected, uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
if err := m.MarkReplied(msg.Answered); err != nil {
|
||||
w.worker.Errorf("could not mark message as answered: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
info, err := m.MessageInfo()
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message info: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: info,
|
||||
}, nil)
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(w.selectedName),
|
||||
}, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleForwardedMessages(msg *types.ForwardedMessages) error {
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.c.Message(*w.selected, uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
if err := m.MarkForwarded(msg.Forwarded); err != nil {
|
||||
w.worker.Errorf("could not mark message as answered: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(w.selectedName),
|
||||
}, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleFlagMessages(msg *types.FlagMessages) error {
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.c.Message(*w.selected, uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
flag := lib.FlagToMaildir[msg.Flags]
|
||||
if err := m.SetOneFlag(flag, msg.Enable); err != nil {
|
||||
w.worker.Errorf("could change flag %v to %v on message: %v", flag, msg.Enable, err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
info, err := m.MessageInfo()
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message info: %v", err)
|
||||
w.err(msg, err)
|
||||
continue
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: info,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(w.selectedName),
|
||||
}, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleCopyMessages(msg *types.CopyMessages) error {
|
||||
dest := w.c.Store.Dir(msg.Destination)
|
||||
err := w.c.CopyAll(dest, *w.selected, msg.Uids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.worker.PostMessage(&types.MessagesCopied{
|
||||
Message: types.RespondTo(msg),
|
||||
Destination: msg.Destination,
|
||||
Uids: msg.Uids,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleMoveMessages(msg *types.MoveMessages) error {
|
||||
dest := w.c.Store.Dir(msg.Destination)
|
||||
moved, err := w.c.MoveAll(dest, *w.selected, msg.Uids)
|
||||
w.worker.PostMessage(&types.MessagesMoved{
|
||||
Message: types.RespondTo(msg),
|
||||
Destination: msg.Destination,
|
||||
Uids: moved,
|
||||
}, nil)
|
||||
w.worker.PostMessage(&types.MessagesDeleted{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: moved,
|
||||
}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error {
|
||||
// since we are the "master" maildir process, we can modify the maildir directly
|
||||
dest := w.c.Store.Dir(msg.Destination)
|
||||
_, writer, err := dest.Create(lib.ToMaildirFlags(msg.Flags))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create message at %s: %w",
|
||||
msg.Destination, err)
|
||||
}
|
||||
defer writer.Close()
|
||||
if _, err := io.Copy(writer, msg.Reader); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not write message to destination: %w", err)
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(msg.Destination),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleSearchDirectory(msg *types.SearchDirectory) error {
|
||||
w.worker.Tracef("Searching with criteria: %#v", msg.Criteria)
|
||||
uids, err := w.search(msg.Context, msg.Criteria)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.worker.PostMessage(&types.SearchResults{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) msgInfoFromUid(uid models.UID) (*models.MessageInfo, error) {
|
||||
m, err := w.c.Message(*w.selected, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := m.MessageInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := m.dir.Filename(m.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.Filenames = []string{name}
|
||||
if w.c.IsRecent(uid) {
|
||||
info.Flags |= models.RecentFlag
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (w *Worker) msgHeadersFromUid(uid models.UID) (*models.MessageInfo, error) {
|
||||
m, err := w.c.Message(*w.selected, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := m.MessageHeaders()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (w *Worker) handleCheckMail(msg *types.CheckMail) {
|
||||
defer log.PanicHandler()
|
||||
if msg.Command == "" {
|
||||
w.err(msg, fmt.Errorf("checkmail: no command specified"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), msg.Timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", msg.Command)
|
||||
ch := make(chan error)
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
_, err := cmd.Output()
|
||||
if err != nil {
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
err = fmt.Errorf("%w\n%s", err, string(exitError.Stderr))
|
||||
}
|
||||
}
|
||||
ch <- err
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.err(msg, fmt.Errorf("checkmail: timed out"))
|
||||
case err := <-ch:
|
||||
if err != nil {
|
||||
w.err(msg, fmt.Errorf("checkmail: error running command: %w", err))
|
||||
} else {
|
||||
dirs, err := w.c.Store.FolderMap()
|
||||
if err != nil {
|
||||
w.err(msg, fmt.Errorf("failed listing directories: %w", err))
|
||||
}
|
||||
for name, dir := range dirs {
|
||||
err := w.c.SyncNewMail(dir)
|
||||
if err != nil {
|
||||
w.err(msg, fmt.Errorf("could not sync new mail: %w", err))
|
||||
}
|
||||
dirInfo := w.getDirectoryInfo(name)
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: dirInfo,
|
||||
}, nil)
|
||||
}
|
||||
w.done(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package mboxer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func createMailboxContainer(path string) (*mailboxContainer, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mbdata := &mailboxContainer{mailboxes: make(map[string]*container)}
|
||||
|
||||
openMboxFile := func(path string, r io.Reader) error {
|
||||
// read mbox file
|
||||
messages, err := Read(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, name := filepath.Split(path)
|
||||
name = strings.TrimSuffix(name, ".mbox")
|
||||
mbdata.mailboxes[name] = &container{filename: path, messages: messages}
|
||||
return nil
|
||||
}
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
files, err := filepath.Glob(filepath.Join(path, "*.mbox"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := openMboxFile(file, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
} else {
|
||||
if err := openMboxFile(path, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return mbdata, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package mboxer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-mbox"
|
||||
)
|
||||
|
||||
func Read(r io.Reader) ([]rfc822.RawMessage, error) {
|
||||
mbr := mbox.NewReader(r)
|
||||
messages := make([]rfc822.RawMessage, 0)
|
||||
for {
|
||||
msg, err := mbr.NextMessage()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages = append(messages, &message{
|
||||
uid: uidFromContents(content),
|
||||
flags: models.SeenFlag,
|
||||
content: content,
|
||||
})
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func Write(w io.Writer, reader io.Reader, from string, date time.Time) error {
|
||||
wc := mbox.NewWriter(w)
|
||||
mw, err := wc.CreateMessage(from, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(mw, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return wc.Close()
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package mboxer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type mailboxContainer struct {
|
||||
mailboxes map[string]*container
|
||||
}
|
||||
|
||||
func (md *mailboxContainer) Names() []string {
|
||||
files := make([]string, 0)
|
||||
for file := range md.mailboxes {
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (md *mailboxContainer) Mailbox(f string) (*container, bool) {
|
||||
mb, ok := md.mailboxes[f]
|
||||
return mb, ok
|
||||
}
|
||||
|
||||
func (md *mailboxContainer) Create(file string) *container {
|
||||
md.mailboxes[file] = &container{filename: file}
|
||||
return md.mailboxes[file]
|
||||
}
|
||||
|
||||
func (md *mailboxContainer) Remove(file string) error {
|
||||
delete(md.mailboxes, file)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (md *mailboxContainer) DirectoryInfo(file string) *models.DirectoryInfo {
|
||||
var exists int
|
||||
if md, ok := md.Mailbox(file); ok {
|
||||
exists = len(md.Uids())
|
||||
}
|
||||
return &models.DirectoryInfo{
|
||||
Name: file,
|
||||
Exists: exists,
|
||||
Recent: 0,
|
||||
Unseen: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (md *mailboxContainer) Copy(dest, src string, uids []models.UID) error {
|
||||
srcmbox, ok := md.Mailbox(src)
|
||||
if !ok {
|
||||
return fmt.Errorf("source %s not found", src)
|
||||
}
|
||||
destmbox, ok := md.Mailbox(dest)
|
||||
if !ok {
|
||||
return fmt.Errorf("destination %s not found", dest)
|
||||
}
|
||||
for _, uidSrc := range srcmbox.Uids() {
|
||||
found := false
|
||||
for _, uid := range uids {
|
||||
if uid == uidSrc {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
msg, err := srcmbox.Message(uidSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get message with uid %s from folder %s", uidSrc, src)
|
||||
}
|
||||
r, err := msg.NewReader()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get reader for message with uid %s", uidSrc)
|
||||
}
|
||||
flags, err := msg.ModelFlags()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get flags for message with uid %s", uidSrc)
|
||||
}
|
||||
err = destmbox.Append(r, flags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not append data to mbox: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
md.mailboxes[dest] = destmbox
|
||||
return nil
|
||||
}
|
||||
|
||||
type container struct {
|
||||
filename string
|
||||
messages []rfc822.RawMessage
|
||||
}
|
||||
|
||||
func (f *container) Uids() []models.UID {
|
||||
uids := make([]models.UID, len(f.messages))
|
||||
for i, m := range f.messages {
|
||||
uids[i] = m.UID()
|
||||
}
|
||||
return uids
|
||||
}
|
||||
|
||||
func (f *container) Message(uid models.UID) (rfc822.RawMessage, error) {
|
||||
for _, m := range f.messages {
|
||||
if uid == m.UID() {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return &message{}, fmt.Errorf("uid [%s] not found", uid)
|
||||
}
|
||||
|
||||
func (f *container) Delete(uids []models.UID) (deleted []models.UID) {
|
||||
newMessages := make([]rfc822.RawMessage, 0)
|
||||
for _, m := range f.messages {
|
||||
del := false
|
||||
for _, uid := range uids {
|
||||
if m.UID() == uid {
|
||||
del = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if del {
|
||||
deleted = append(deleted, m.UID())
|
||||
} else {
|
||||
newMessages = append(newMessages, m)
|
||||
}
|
||||
}
|
||||
f.messages = newMessages
|
||||
return
|
||||
}
|
||||
|
||||
func (f *container) Append(r io.Reader, flags models.Flags) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.messages = append(f.messages, &message{
|
||||
uid: uidFromContents(data),
|
||||
flags: flags,
|
||||
content: data,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func uidFromContents(data []byte) models.UID {
|
||||
sum := sha256.New()
|
||||
sum.Write(data)
|
||||
return models.UID(hex.EncodeToString(sum.Sum(nil)))
|
||||
}
|
||||
|
||||
// message implements the lib.RawMessage interface
|
||||
type message struct {
|
||||
uid models.UID
|
||||
flags models.Flags
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (m *message) NewReader() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(m.content)), nil
|
||||
}
|
||||
|
||||
func (m *message) ModelFlags() (models.Flags, error) {
|
||||
return m.flags, nil
|
||||
}
|
||||
|
||||
func (m *message) Labels() ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *message) UID() models.UID {
|
||||
return m.uid
|
||||
}
|
||||
|
||||
func (m *message) SetFlag(flag models.Flags, state bool) error {
|
||||
if state {
|
||||
m.flags |= flag
|
||||
} else {
|
||||
m.flags &^= flag
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package mboxer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/handlers"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
handlers.RegisterWorkerFactory("mbox", NewWorker)
|
||||
}
|
||||
|
||||
var errUnsupported = fmt.Errorf("unsupported command")
|
||||
|
||||
type mboxWorker struct {
|
||||
data *mailboxContainer
|
||||
name string
|
||||
folder *container
|
||||
worker *types.Worker
|
||||
|
||||
capabilities *models.Capabilities
|
||||
headers []string
|
||||
headersExclude []string
|
||||
}
|
||||
|
||||
func NewWorker(worker *types.Worker) (types.Backend, error) {
|
||||
return &mboxWorker{
|
||||
worker: worker,
|
||||
capabilities: &models.Capabilities{
|
||||
Sort: true,
|
||||
Thread: false,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error {
|
||||
var reterr error // will be returned at the end, needed to support idle
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case *types.Unsupported:
|
||||
// No-op
|
||||
|
||||
case *types.Configure:
|
||||
u, err := url.Parse(msg.Config.Source)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
if u.Host == "" && u.Path == "" {
|
||||
u, err = url.Parse("mbox://" + u.Opaque)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var dir string
|
||||
if u.Host == "~" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
dir = filepath.Join(home, u.Path)
|
||||
} else {
|
||||
dir = filepath.Join(u.Host, u.Path)
|
||||
}
|
||||
w.headers = msg.Config.Headers
|
||||
w.headersExclude = msg.Config.HeadersExclude
|
||||
w.data, err = createMailboxContainer(dir)
|
||||
if err != nil || w.data == nil {
|
||||
w.data = &mailboxContainer{
|
||||
mailboxes: make(map[string]*container),
|
||||
}
|
||||
reterr = err
|
||||
break
|
||||
} else {
|
||||
w.worker.Debugf("configured with mbox file %s", dir)
|
||||
}
|
||||
|
||||
case *types.Connect, *types.Reconnect, *types.Disconnect:
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.ListDirectories:
|
||||
dirs := w.data.Names()
|
||||
sort.Strings(dirs)
|
||||
for _, name := range dirs {
|
||||
w.worker.PostMessage(&types.Directory{
|
||||
Message: types.RespondTo(msg),
|
||||
Dir: &models.Directory{
|
||||
Name: name,
|
||||
},
|
||||
}, nil)
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(name),
|
||||
}, nil)
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.OpenDirectory:
|
||||
w.name = msg.Directory
|
||||
var ok bool
|
||||
w.folder, ok = w.data.Mailbox(w.name)
|
||||
if !ok {
|
||||
w.folder = w.data.Create(w.name)
|
||||
w.worker.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(&types.CreateDirectory{}),
|
||||
}, nil)
|
||||
}
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(msg.Directory),
|
||||
}, nil)
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
w.worker.Debugf("%s opened", msg.Directory)
|
||||
|
||||
case *types.FetchDirectoryContents:
|
||||
uids, err := filterUids(w.folder, w.folder.Uids(), msg.Filter)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
uids, err = sortUids(w.folder, uids, msg.SortCriteria)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
if len(uids) == 0 {
|
||||
reterr = fmt.Errorf("mbox: no uids in directory")
|
||||
break
|
||||
}
|
||||
w.worker.PostMessage(&types.DirectoryContents{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.FetchDirectoryThreaded:
|
||||
reterr = errUnsupported
|
||||
|
||||
case *types.CreateDirectory:
|
||||
w.data.Create(msg.Directory)
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.RemoveDirectory:
|
||||
if err := w.data.Remove(msg.Directory); err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.FetchMessageHeaders:
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.folder.Message(uid)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
msgInfo, err := messageInfo(m, true)
|
||||
if err != nil {
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Info: &models.MessageInfo{
|
||||
Envelope: &models.Envelope{},
|
||||
Flags: models.SeenFlag,
|
||||
Uid: uid,
|
||||
Error: err,
|
||||
},
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
continue
|
||||
} else {
|
||||
switch {
|
||||
case len(w.headersExclude) > 0:
|
||||
msgInfo.RFC822Headers = lib.LimitHeaders(msgInfo.RFC822Headers, w.headersExclude, true)
|
||||
case len(w.headers) > 0:
|
||||
msgInfo.RFC822Headers = lib.LimitHeaders(msgInfo.RFC822Headers, w.headers, false)
|
||||
}
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: msgInfo,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
w.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.FetchMessageBodyPart:
|
||||
m, err := w.folder.Message(msg.Uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message %d: %v", msg.Uid, err)
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
|
||||
contentReader, err := m.NewReader()
|
||||
if err != nil {
|
||||
reterr = fmt.Errorf("could not get message reader: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
fullMsg, err := rfc822.ReadMessage(contentReader)
|
||||
if err != nil {
|
||||
reterr = fmt.Errorf("could not read message: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
r, err := rfc822.FetchEntityPartReader(fullMsg, msg.Part)
|
||||
if err != nil {
|
||||
w.worker.Errorf(
|
||||
"could not get body part reader for message=%d, parts=%#v: %w",
|
||||
msg.Uid, msg.Part, err)
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.MessageBodyPart{
|
||||
Message: types.RespondTo(msg),
|
||||
Part: &models.MessageBodyPart{
|
||||
Reader: r,
|
||||
Uid: msg.Uid,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
case *types.FetchFullMessages:
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.folder.Message(uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message for uid %d: %v", uid, err)
|
||||
continue
|
||||
}
|
||||
r, err := m.NewReader()
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message reader: %v", err)
|
||||
continue
|
||||
}
|
||||
defer r.Close()
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message reader: %v", err)
|
||||
continue
|
||||
}
|
||||
w.worker.PostMessage(&types.FullMessage{
|
||||
Message: types.RespondTo(msg),
|
||||
Content: &models.FullMessage{
|
||||
Uid: uid,
|
||||
Reader: bytes.NewReader(b),
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
w.worker.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
|
||||
case *types.DeleteMessages:
|
||||
deleted := w.folder.Delete(msg.Uids)
|
||||
if len(deleted) > 0 {
|
||||
w.worker.PostMessage(&types.MessagesDeleted{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: deleted,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(w.name),
|
||||
}, nil)
|
||||
|
||||
w.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.FlagMessages:
|
||||
for _, uid := range msg.Uids {
|
||||
m, err := w.folder.Message(uid)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := m.(*message).SetFlag(msg.Flags, msg.Enable); err != nil {
|
||||
w.worker.Errorf("could not change flag %v to %t on message: %v",
|
||||
msg.Flags, msg.Enable, err)
|
||||
continue
|
||||
}
|
||||
info, err := rfc822.MessageInfo(m)
|
||||
if err != nil {
|
||||
w.worker.Errorf("could not get message info: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.MessageInfo{
|
||||
Message: types.RespondTo(msg),
|
||||
Info: info,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(w.name),
|
||||
}, nil)
|
||||
|
||||
w.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.CopyMessages:
|
||||
err := w.data.Copy(msg.Destination, w.name, msg.Uids)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(w.name),
|
||||
}, nil)
|
||||
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(msg.Destination),
|
||||
}, nil)
|
||||
|
||||
w.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
case *types.MoveMessages:
|
||||
err := w.data.Copy(msg.Destination, w.name, msg.Uids)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
deleted := w.folder.Delete(msg.Uids)
|
||||
if len(deleted) > 0 {
|
||||
w.worker.PostMessage(&types.MessagesDeleted{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: deleted,
|
||||
}, nil)
|
||||
}
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(msg.Destination),
|
||||
}, nil)
|
||||
w.worker.PostMessage(
|
||||
&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
case *types.SearchDirectory:
|
||||
uids, err := filterUids(w.folder, w.folder.Uids(), msg.Criteria)
|
||||
if err != nil {
|
||||
reterr = err
|
||||
break
|
||||
}
|
||||
w.worker.PostMessage(&types.SearchResults{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
|
||||
case *types.AppendMessage:
|
||||
if msg.Destination == "" {
|
||||
reterr = fmt.Errorf("AppendMessage with empty destination directory")
|
||||
break
|
||||
}
|
||||
folder, ok := w.data.Mailbox(msg.Destination)
|
||||
if !ok {
|
||||
folder = w.data.Create(msg.Destination)
|
||||
w.worker.PostMessage(&types.Done{
|
||||
Message: types.RespondTo(&types.CreateDirectory{}),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
if err := folder.Append(msg.Reader, msg.Flags); err != nil {
|
||||
reterr = err
|
||||
break
|
||||
} else {
|
||||
w.worker.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.data.DirectoryInfo(msg.Destination),
|
||||
}, nil)
|
||||
w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
}
|
||||
|
||||
case *types.AnsweredMessages:
|
||||
reterr = errUnsupported
|
||||
default:
|
||||
reterr = errUnsupported
|
||||
}
|
||||
|
||||
return reterr
|
||||
}
|
||||
|
||||
func (w *mboxWorker) Run() {
|
||||
for msg := range w.worker.Actions() {
|
||||
msg = w.worker.ProcessAction(msg)
|
||||
if err := w.handleMessage(msg); errors.Is(err, errUnsupported) {
|
||||
w.worker.PostMessage(&types.Unsupported{
|
||||
Message: types.RespondTo(msg),
|
||||
}, nil)
|
||||
} else if err != nil {
|
||||
w.worker.PostMessage(&types.Error{
|
||||
Message: types.RespondTo(msg),
|
||||
Error: err,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *mboxWorker) Capabilities() *models.Capabilities {
|
||||
return w.capabilities
|
||||
}
|
||||
|
||||
func (w *mboxWorker) PathSeparator() string {
|
||||
return "/"
|
||||
}
|
||||
|
||||
func filterUids(folder *container, uids []models.UID, criteria *types.SearchCriteria) ([]models.UID, error) {
|
||||
log.Debugf("Search with parsed criteria: %#v", criteria)
|
||||
m := make([]rfc822.RawMessage, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
msg, err := folder.Message(uid)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get message for uid: %d", uid)
|
||||
continue
|
||||
}
|
||||
m = append(m, msg)
|
||||
}
|
||||
return lib.Search(m, criteria)
|
||||
}
|
||||
|
||||
func sortUids(folder *container, uids []models.UID,
|
||||
criteria []*types.SortCriterion,
|
||||
) ([]models.UID, error) {
|
||||
var infos []*models.MessageInfo
|
||||
needSize := false
|
||||
for _, item := range criteria {
|
||||
if item.Field == types.SortSize {
|
||||
needSize = true
|
||||
}
|
||||
}
|
||||
for _, uid := range uids {
|
||||
m, err := folder.Message(uid)
|
||||
if err != nil {
|
||||
log.Errorf("could not get message %v", err)
|
||||
continue
|
||||
}
|
||||
info, err := messageInfo(m, needSize)
|
||||
if err != nil {
|
||||
log.Errorf("could not get message info %v", err)
|
||||
continue
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
return lib.Sort(infos, criteria)
|
||||
}
|
||||
|
||||
func messageInfo(m rfc822.RawMessage, needSize bool) (*models.MessageInfo, error) {
|
||||
info, err := rfc822.MessageInfo(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !needSize {
|
||||
return info, nil
|
||||
}
|
||||
r, err := m.NewReader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.Size = uint32(size)
|
||||
return info, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type folderMapper struct {
|
||||
sync.Mutex
|
||||
types.WorkerInteractor
|
||||
fm folderMap
|
||||
table map[string]string
|
||||
}
|
||||
|
||||
func NewFolderMapper(base types.WorkerInteractor, mapping map[string]string,
|
||||
order []string,
|
||||
) types.WorkerInteractor {
|
||||
base.Infof("loading worker middleware: foldermapper")
|
||||
return &folderMapper{
|
||||
WorkerInteractor: base,
|
||||
fm: folderMap{mapping, order},
|
||||
table: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *folderMapper) Unwrap() types.WorkerInteractor {
|
||||
return f.WorkerInteractor
|
||||
}
|
||||
|
||||
func (f *folderMapper) incoming(msg types.WorkerMessage, dir string) string {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
mapped, ok := f.table[dir]
|
||||
if !ok {
|
||||
return dir
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
func (f *folderMapper) outgoing(msg types.WorkerMessage, dir string) string {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
for k, v := range f.table {
|
||||
if v == dir {
|
||||
mapped := k
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (f *folderMapper) store(s string) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
display := f.fm.Apply(s)
|
||||
f.table[display] = s
|
||||
f.Tracef("store display folder '%s' to '%s'", display, s)
|
||||
}
|
||||
|
||||
func (f *folderMapper) create(s string) (string, error) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
backend := createFolder(f.table, s)
|
||||
if _, exists := f.table[s]; exists {
|
||||
return s, fmt.Errorf("folder already exists: %s", s)
|
||||
}
|
||||
f.table[s] = backend
|
||||
f.Tracef("create display folder '%s' as '%s'", s, backend)
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func (f *folderMapper) ProcessAction(msg types.WorkerMessage) types.WorkerMessage {
|
||||
switch msg := msg.(type) {
|
||||
case *types.CheckMail:
|
||||
for i := range msg.Directories {
|
||||
msg.Directories[i] = f.incoming(msg, msg.Directories[i])
|
||||
}
|
||||
case *types.CopyMessages:
|
||||
msg.Destination = f.incoming(msg, msg.Destination)
|
||||
case *types.AppendMessage:
|
||||
msg.Destination = f.incoming(msg, msg.Destination)
|
||||
case *types.MoveMessages:
|
||||
msg.Destination = f.incoming(msg, msg.Destination)
|
||||
case *types.CreateDirectory:
|
||||
var err error
|
||||
msg.Directory, err = f.create(msg.Directory)
|
||||
if err != nil {
|
||||
f.Errorf("error creating new directory: %v", err)
|
||||
}
|
||||
case *types.RemoveDirectory:
|
||||
msg.Directory = f.incoming(msg, msg.Directory)
|
||||
case *types.OpenDirectory:
|
||||
msg.Directory = f.incoming(msg, msg.Directory)
|
||||
}
|
||||
|
||||
return f.WorkerInteractor.ProcessAction(msg)
|
||||
}
|
||||
|
||||
func (f *folderMapper) PostMessage(msg types.WorkerMessage, cb func(m types.WorkerMessage)) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
switch msg := msg.InResponseTo().(type) {
|
||||
case *types.CheckMail:
|
||||
for i := range msg.Directories {
|
||||
msg.Directories[i] = f.outgoing(msg, msg.Directories[i])
|
||||
}
|
||||
case *types.CopyMessages:
|
||||
msg.Destination = f.outgoing(msg, msg.Destination)
|
||||
case *types.AppendMessage:
|
||||
msg.Destination = f.outgoing(msg, msg.Destination)
|
||||
case *types.MoveMessages:
|
||||
msg.Destination = f.outgoing(msg, msg.Destination)
|
||||
case *types.CreateDirectory:
|
||||
msg.Directory = f.outgoing(msg, msg.Directory)
|
||||
case *types.RemoveDirectory:
|
||||
msg.Directory = f.outgoing(msg, msg.Directory)
|
||||
case *types.OpenDirectory:
|
||||
msg.Directory = f.outgoing(msg, msg.Directory)
|
||||
}
|
||||
case *types.CheckMailDirectories:
|
||||
for i := range msg.Directories {
|
||||
msg.Directories[i] = f.outgoing(msg, msg.Directories[i])
|
||||
}
|
||||
case *types.Directory:
|
||||
f.store(msg.Dir.Name)
|
||||
msg.Dir.Name = f.outgoing(msg, msg.Dir.Name)
|
||||
case *types.DirectoryInfo:
|
||||
msg.Info.Name = f.outgoing(msg, msg.Info.Name)
|
||||
}
|
||||
f.WorkerInteractor.PostMessage(msg, cb)
|
||||
}
|
||||
|
||||
// folderMap contains the mapping between the ui and backend folder names
|
||||
type folderMap struct {
|
||||
mapping map[string]string
|
||||
order []string
|
||||
}
|
||||
|
||||
// Apply applies the mapping from the folder map to the backend folder
|
||||
func (f *folderMap) Apply(s string) string {
|
||||
for _, k := range f.order {
|
||||
v := f.mapping[k]
|
||||
strict := true
|
||||
if strings.HasSuffix(v, "*") {
|
||||
v = strings.TrimSuffix(v, "*")
|
||||
strict = false
|
||||
}
|
||||
if (strings.HasPrefix(s, v) && !strict) || (s == v && strict) {
|
||||
term := strings.TrimPrefix(s, v)
|
||||
if strings.Contains(k, "*") && !strict {
|
||||
prefix := k
|
||||
for strings.Contains(prefix, "**") {
|
||||
prefix = strings.ReplaceAll(prefix, "**", "*")
|
||||
}
|
||||
s = strings.Replace(prefix, "*", term, 1)
|
||||
} else {
|
||||
s = k + term
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// createFolder reverses the mapping of a new folder name
|
||||
func createFolder(table map[string]string, s string) string {
|
||||
max, key := 0, ""
|
||||
for k := range table {
|
||||
if strings.HasPrefix(s, k) && len(k) > max {
|
||||
max, key = len(k), k
|
||||
}
|
||||
}
|
||||
if max > 0 && key != "" {
|
||||
s = table[key] + strings.TrimPrefix(s, key)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFolderMap_Apply(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mapping map[string]string
|
||||
order []string
|
||||
input []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "strict single folder mapping",
|
||||
mapping: map[string]string{"Drafts": "INBOX/Drafts"},
|
||||
order: []string{"Drafts"},
|
||||
input: []string{"INBOX/Drafts"},
|
||||
want: []string{"Drafts"},
|
||||
},
|
||||
{
|
||||
name: "prefix mapping with * suffix",
|
||||
mapping: map[string]string{"Prefix/": "INBOX/*"},
|
||||
order: []string{"Prefix/"},
|
||||
input: []string{"INBOX", "INBOX/Test1", "INBOX/Test2", "Archive"},
|
||||
want: []string{"INBOX", "Prefix/Test1", "Prefix/Test2", "Archive"},
|
||||
},
|
||||
{
|
||||
name: "remove prefix with * in key",
|
||||
mapping: map[string]string{"*": "INBOX/*"},
|
||||
order: []string{"*"},
|
||||
input: []string{"INBOX", "INBOX/Test1", "INBOX/Test2", "Archive"},
|
||||
want: []string{"INBOX", "Test1", "Test2", "Archive"},
|
||||
},
|
||||
{
|
||||
name: "remove two prefixes with * in keys",
|
||||
mapping: map[string]string{
|
||||
"*": "INBOX/*",
|
||||
"**": "PROJECT/*",
|
||||
},
|
||||
order: []string{"*", "**"},
|
||||
input: []string{"INBOX", "INBOX/Test1", "INBOX/Test2", "Archive", "PROJECT/sub1", "PROJECT/sub2"},
|
||||
want: []string{"INBOX", "Test1", "Test2", "Archive", "sub1", "sub2"},
|
||||
},
|
||||
{
|
||||
name: "multiple, sequential mappings",
|
||||
mapping: map[string]string{
|
||||
"Archive/existing": "Archive*",
|
||||
"Archive": "Archivum*",
|
||||
},
|
||||
order: []string{"Archive/existing", "Archive"},
|
||||
input: []string{"Archive", "Archive/sub", "Archivum", "Archivum/year1"},
|
||||
want: []string{"Archive/existing", "Archive/existing/sub", "Archive", "Archive/year1"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
fm := &folderMap{
|
||||
mapping: test.mapping,
|
||||
order: test.order,
|
||||
}
|
||||
var result []string
|
||||
for _, in := range test.input {
|
||||
result = append(result, fm.Apply(in))
|
||||
}
|
||||
if !reflect.DeepEqual(result, test.want) {
|
||||
t.Errorf("test (%d: %s) failed: want '%v' but got '%v'",
|
||||
i, test.name, test.want, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderMap_createFolder(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
table map[string]string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "create normal folder",
|
||||
table: map[string]string{"Drafts": "INBOX/Drafts"},
|
||||
input: "INBOX/Drafts2",
|
||||
want: "INBOX/Drafts2",
|
||||
},
|
||||
{
|
||||
name: "create mapped folder",
|
||||
table: map[string]string{"Drafts": "INBOX/Drafts"},
|
||||
input: "Drafts/Sub",
|
||||
want: "INBOX/Drafts/Sub",
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
result := createFolder(test.table, test.input)
|
||||
if result != test.want {
|
||||
t.Errorf("test (%d: %s) failed: want '%v' but got '%v'",
|
||||
i, test.name, test.want, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-imap/client"
|
||||
)
|
||||
|
||||
type gmailWorker struct {
|
||||
types.WorkerInteractor
|
||||
mu sync.Mutex
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
// NewGmailWorker returns an IMAP middleware for the X-GM-EXT-1 extension
|
||||
func NewGmailWorker(base types.WorkerInteractor, c *client.Client,
|
||||
) types.WorkerInteractor {
|
||||
base.Infof("loading worker middleware: X-GM-EXT-1")
|
||||
|
||||
// avoid double wrapping; unwrap and check for another gmail handler
|
||||
for iter := base; iter != nil; iter = iter.Unwrap() {
|
||||
if g, ok := iter.(*gmailWorker); ok {
|
||||
base.Infof("already loaded; resetting")
|
||||
err := g.reset(c)
|
||||
if err != nil {
|
||||
base.Errorf("reset failed: %v", err)
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
return &gmailWorker{WorkerInteractor: base, client: c}
|
||||
}
|
||||
|
||||
func (g *gmailWorker) Unwrap() types.WorkerInteractor {
|
||||
return g.WorkerInteractor
|
||||
}
|
||||
|
||||
func (g *gmailWorker) reset(c *client.Client) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.client = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gmailWorker) ProcessAction(msg types.WorkerMessage) types.WorkerMessage {
|
||||
switch msg := msg.(type) {
|
||||
case *types.FetchMessageHeaders:
|
||||
handler := xgmext.NewHandler(g.client)
|
||||
|
||||
g.mu.Lock()
|
||||
uids, err := handler.FetchEntireThreads(msg.Uids)
|
||||
g.mu.Unlock()
|
||||
if err != nil {
|
||||
g.Warnf("failed to fetch entire threads: %v", err)
|
||||
}
|
||||
|
||||
if len(uids) > 0 {
|
||||
msg.Uids = uids
|
||||
}
|
||||
|
||||
case *types.FetchDirectoryContents:
|
||||
if msg.Filter == nil || (msg.Filter != nil &&
|
||||
len(msg.Filter.Terms) == 0) {
|
||||
break
|
||||
}
|
||||
if !msg.Filter.UseExtension {
|
||||
g.Debugf("use regular imap filter instead of X-GM-EXT1: " +
|
||||
"extension flag not set")
|
||||
break
|
||||
}
|
||||
|
||||
search := strings.Join(msg.Filter.Terms, " ")
|
||||
g.Debugf("X-GM-EXT1 filter term: '%s'", search)
|
||||
|
||||
handler := xgmext.NewHandler(g.client)
|
||||
|
||||
g.mu.Lock()
|
||||
uids, err := handler.RawSearch(strconv.Quote(search))
|
||||
g.mu.Unlock()
|
||||
if err != nil {
|
||||
g.Errorf("X-GM-EXT1 filter failed: %v", err)
|
||||
g.Warnf("falling back to imap filtering")
|
||||
break
|
||||
}
|
||||
|
||||
g.PostMessage(&types.DirectoryContents{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
|
||||
g.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
return &types.Unsupported{}
|
||||
|
||||
case *types.SearchDirectory:
|
||||
if msg.Criteria == nil || (msg.Criteria != nil &&
|
||||
len(msg.Criteria.Terms) == 0) {
|
||||
break
|
||||
}
|
||||
if !msg.Criteria.UseExtension {
|
||||
g.Debugf("use regular imap search instead of X-GM-EXT1: " +
|
||||
"extension flag not set")
|
||||
break
|
||||
}
|
||||
|
||||
search := strings.Join(msg.Criteria.Terms, " ")
|
||||
g.Debugf("X-GM-EXT1 search term: '%s'", search)
|
||||
handler := xgmext.NewHandler(g.client)
|
||||
|
||||
g.mu.Lock()
|
||||
uids, err := handler.RawSearch(strconv.Quote(search))
|
||||
g.mu.Unlock()
|
||||
if err != nil {
|
||||
g.Errorf("X-GM-EXT1 search failed: %v", err)
|
||||
g.Warnf("falling back to regular imap search.")
|
||||
break
|
||||
}
|
||||
|
||||
g.PostMessage(&types.SearchResults{
|
||||
Message: types.RespondTo(msg),
|
||||
Uids: uids,
|
||||
}, nil)
|
||||
|
||||
g.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil)
|
||||
|
||||
return &types.Unsupported{}
|
||||
}
|
||||
return g.WorkerInteractor.ProcessAction(msg)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package notmuch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
func (w *worker) handleNotmuchEvent() error {
|
||||
err := w.db.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.db.Close()
|
||||
err = w.updateDirCounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.updateChangedMessages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.emitLabelList()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *worker) updateDirCounts() error {
|
||||
if w.store != nil {
|
||||
folders, err := w.store.FolderMap()
|
||||
if err != nil {
|
||||
w.w.Errorf("failed listing directories: %v", err)
|
||||
return err
|
||||
}
|
||||
for name := range folders {
|
||||
folder := filepath.Join(w.maildirAccountPath, name)
|
||||
query := fmt.Sprintf("folder:%s", strconv.Quote(folder))
|
||||
w.w.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(name, query),
|
||||
Refetch: w.query == query,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
for name, query := range w.nameQueryMap {
|
||||
w.w.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(name, query),
|
||||
Refetch: w.query == query,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
for name, query := range w.dynamicNameQueryMap {
|
||||
w.w.PostMessage(&types.DirectoryInfo{
|
||||
Info: w.getDirectoryInfo(name, query),
|
||||
Refetch: w.query == query,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *worker) updateChangedMessages() error {
|
||||
newState := w.db.State()
|
||||
if newState == w.state {
|
||||
return nil
|
||||
}
|
||||
w.w.Logger.Debugf("State change: %d to %d", w.state, newState)
|
||||
query := fmt.Sprintf("lastmod:%d..%d and (%s)", w.state, newState, w.query)
|
||||
uids, err := w.uidsFromQuery(context.TODO(), query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Couldn't get updates messages: %w", err)
|
||||
}
|
||||
for _, uid := range uids {
|
||||
m, err := w.msgFromUid(uid)
|
||||
if err != nil {
|
||||
log.Errorf("%s", err)
|
||||
continue
|
||||
}
|
||||
err = w.emitMessageInfo(m, nil)
|
||||
if err != nil {
|
||||
log.Errorf("%s", err)
|
||||
}
|
||||
}
|
||||
w.state = newState
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/notmuch"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
path string
|
||||
excludedTags []string
|
||||
db *notmuch.Database
|
||||
}
|
||||
|
||||
func NewDB(path string, excludedTags []string) *DB {
|
||||
nm := ¬much.Database{
|
||||
Path: path,
|
||||
}
|
||||
db := &DB{
|
||||
path: path,
|
||||
excludedTags: excludedTags,
|
||||
db: nm,
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (db *DB) Connect() error {
|
||||
return db.db.Open(notmuch.MODE_READ_ONLY)
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.db.Close()
|
||||
}
|
||||
|
||||
// Returns the DB path
|
||||
func (db *DB) Path() string {
|
||||
return db.db.ResolvedPath()
|
||||
}
|
||||
|
||||
// ListTags lists all known tags
|
||||
func (db *DB) ListTags() []string {
|
||||
return db.db.Tags()
|
||||
}
|
||||
|
||||
// State returns the lastmod of the database. This is a uin64 which is
|
||||
// incremented with every modification
|
||||
func (db *DB) State() uint64 {
|
||||
_, lastmod := db.db.Revision()
|
||||
return lastmod
|
||||
}
|
||||
|
||||
// getQuery returns a query based on the provided query string.
|
||||
// It also configures the query as specified on the worker
|
||||
func (db *DB) newQuery(query string) (*notmuch.Query, error) {
|
||||
q, err := db.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.Exclude(notmuch.EXCLUDE_ALL)
|
||||
q.Sort(notmuch.SORT_OLDEST_FIRST)
|
||||
for _, t := range db.excludedTags {
|
||||
err := q.ExcludeTag(t)
|
||||
// do not treat STATUS_IGNORED as an error; this allows explicit
|
||||
// searches using tags that are excluded by default
|
||||
if err != nil && !errors.Is(err, notmuch.STATUS_IGNORED) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (db *DB) MsgIDFromFilename(filename string) (string, error) {
|
||||
msg, err := db.db.FindMessageByFilename(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer msg.Close()
|
||||
return msg.ID(), nil
|
||||
}
|
||||
|
||||
func (db *DB) MsgIDsFromQuery(ctx context.Context, q string) ([]string, error) {
|
||||
query, err := db.newQuery(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer query.Close()
|
||||
messages, err := query.Messages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer messages.Close()
|
||||
var msgIDs []string
|
||||
for messages.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, context.Canceled
|
||||
default:
|
||||
msg := messages.Message()
|
||||
defer msg.Close()
|
||||
msgIDs = append(msgIDs, msg.ID())
|
||||
}
|
||||
}
|
||||
return msgIDs, err
|
||||
}
|
||||
|
||||
func (db *DB) ThreadsFromQuery(ctx context.Context, q string, entireThread bool) ([]*types.Thread, error) {
|
||||
query, err := db.newQuery(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer query.Close()
|
||||
// To get proper ordering of threads, we always sort newest first
|
||||
query.Sort(notmuch.SORT_NEWEST_FIRST)
|
||||
threads, err := query.Threads()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := query.CountMessages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer threads.Close()
|
||||
res := make([]*types.Thread, 0, n)
|
||||
for threads.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, context.Canceled
|
||||
default:
|
||||
thread := threads.Thread()
|
||||
tlm := thread.TopLevelMessages()
|
||||
root := db.makeThread(nil, &tlm, entireThread)
|
||||
if len(root) > 1 {
|
||||
root[0].Dummy = true
|
||||
fc := &(root[0].FirstChild)
|
||||
for ; *fc != nil; fc = &((*fc).NextSibling) {
|
||||
}
|
||||
*fc = root[0].NextSibling
|
||||
root[0].NextSibling.PrevSibling = nil
|
||||
root[0].NextSibling = nil
|
||||
for i := 1; i < len(root); i++ {
|
||||
root[i].Parent = root[0]
|
||||
}
|
||||
res = append(res, root[0])
|
||||
} else {
|
||||
res = append(res, root...)
|
||||
}
|
||||
tlm.Close()
|
||||
thread.Close()
|
||||
}
|
||||
}
|
||||
// Reverse the slice
|
||||
for i, j := 0, len(res)-1; i < j; i, j = i+1, j-1 {
|
||||
res[i], res[j] = res[j], res[i]
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
type MessageCount struct {
|
||||
Exists int
|
||||
Unread int
|
||||
}
|
||||
|
||||
func (db *DB) QueryCountMessages(q string) (MessageCount, error) {
|
||||
count := MessageCount{}
|
||||
query, err := db.newQuery(q)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
defer query.Close()
|
||||
count.Exists, err = query.CountMessages()
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
unreadQuery, err := db.newQuery(AndQueries(q, "tag:unread"))
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
defer unreadQuery.Close()
|
||||
count.Unread, err = unreadQuery.CountMessages()
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (db *DB) MsgFilename(key string) (string, error) {
|
||||
msg, err := db.db.FindMessageByID(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer msg.Close()
|
||||
return msg.Filename(), nil
|
||||
}
|
||||
|
||||
func (db *DB) MsgTags(key string) ([]string, error) {
|
||||
msg, err := db.db.FindMessageByID(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer msg.Close()
|
||||
return msg.Tags(), nil
|
||||
}
|
||||
|
||||
func (db *DB) MsgFilenames(key string) ([]string, error) {
|
||||
msg, err := db.db.FindMessageByID(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer msg.Close()
|
||||
return msg.Filenames(), nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteMessage(filename string) error {
|
||||
err := db.db.Reopen(notmuch.MODE_READ_WRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.db.Reopen(notmuch.MODE_READ_ONLY); err != nil {
|
||||
log.Errorf("couldn't reopen: %s", err)
|
||||
}
|
||||
}()
|
||||
err = db.db.BeginAtomic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.db.EndAtomic(); err != nil {
|
||||
log.Errorf("couldn't end atomic: %s", err)
|
||||
}
|
||||
}()
|
||||
err = db.db.RemoveFile(filename)
|
||||
if err != nil && !errors.Is(err, notmuch.STATUS_DUPLICATE_MESSAGE_ID) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) IndexFile(filename string) (string, error) {
|
||||
err := db.db.Reopen(notmuch.MODE_READ_WRITE)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.db.Reopen(notmuch.MODE_READ_ONLY); err != nil {
|
||||
log.Errorf("couldn't reopen: %s", err)
|
||||
}
|
||||
}()
|
||||
err = db.db.BeginAtomic()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.db.EndAtomic(); err != nil {
|
||||
log.Errorf("couldn't end atomic: %s", err)
|
||||
}
|
||||
}()
|
||||
msg, err := db.db.IndexFile(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer msg.Close()
|
||||
return msg.ID(), nil
|
||||
}
|
||||
|
||||
func (db *DB) MsgModifyTags(key string, add, remove []string) error {
|
||||
err := db.db.Reopen(notmuch.MODE_READ_WRITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.db.Reopen(notmuch.MODE_READ_ONLY); err != nil {
|
||||
log.Errorf("couldn't reopen: %s", err)
|
||||
}
|
||||
}()
|
||||
err = db.db.BeginAtomic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.db.EndAtomic(); err != nil {
|
||||
log.Errorf("couldn't end atomic: %s", err)
|
||||
}
|
||||
}()
|
||||
msg, err := db.db.FindMessageByID(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer msg.Close()
|
||||
for _, tag := range add {
|
||||
err := msg.AddTag(tag)
|
||||
if err != nil {
|
||||
log.Warnf("failed to add tag: %v", err)
|
||||
}
|
||||
}
|
||||
for _, tag := range remove {
|
||||
err := msg.RemoveTag(tag)
|
||||
if err != nil {
|
||||
log.Warnf("failed to add tag: %v", err)
|
||||
}
|
||||
}
|
||||
return msg.SyncTagsToMaildirFlags()
|
||||
}
|
||||
|
||||
func (db *DB) makeThread(parent *types.Thread, msgs *notmuch.Messages, threadContext bool) []*types.Thread {
|
||||
var siblings []*types.Thread
|
||||
for msgs.Next() {
|
||||
msg := msgs.Message()
|
||||
defer msg.Close()
|
||||
msgID := msg.ID()
|
||||
match, err := msg.Flag(notmuch.MESSAGE_FLAG_MATCH)
|
||||
if err != nil {
|
||||
log.Errorf("%s", err)
|
||||
continue
|
||||
}
|
||||
replies := msg.Replies()
|
||||
defer replies.Close()
|
||||
if !match && !threadContext {
|
||||
siblings = append(siblings, db.makeThread(parent, &replies, threadContext)...)
|
||||
continue
|
||||
}
|
||||
node := &types.Thread{
|
||||
Uid: models.UID(msgID),
|
||||
Parent: parent,
|
||||
}
|
||||
switch threadContext {
|
||||
case true:
|
||||
node.Context = !match
|
||||
default:
|
||||
if match {
|
||||
node.Hidden = 0
|
||||
} else {
|
||||
node.Hidden = 1
|
||||
}
|
||||
}
|
||||
if parent != nil && parent.FirstChild == nil {
|
||||
parent.FirstChild = node
|
||||
}
|
||||
siblings = append(siblings, node)
|
||||
db.makeThread(node, &replies, threadContext)
|
||||
}
|
||||
for i := 1; i < len(siblings); i++ {
|
||||
siblings[i-1].NextSibling = siblings[i]
|
||||
}
|
||||
return siblings
|
||||
}
|
||||
|
||||
func AndQueries(q1, q2 string) string {
|
||||
if q1 == "" {
|
||||
return q2
|
||||
}
|
||||
if q2 == "" {
|
||||
return q1
|
||||
}
|
||||
if q1 == "*" {
|
||||
return q2
|
||||
}
|
||||
if q2 == "*" {
|
||||
return q1
|
||||
}
|
||||
return fmt.Sprintf("(%s) and (%s)", q1, q2)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package notmuch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-maildir"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/rfc822"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/lib"
|
||||
notmuch "git.sr.ht/~rjarry/aerc/worker/notmuch/lib"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
uid models.UID
|
||||
key string
|
||||
db *notmuch.DB
|
||||
}
|
||||
|
||||
// NewReader returns a reader for a message
|
||||
func (m *Message) NewReader() (io.ReadCloser, error) {
|
||||
name, err := m.Filename()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(name)
|
||||
}
|
||||
|
||||
// MessageInfo populates a models.MessageInfo struct for the message.
|
||||
func (m *Message) MessageInfo() (*models.MessageInfo, error) {
|
||||
info, err := rfc822.MessageInfo(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filenames, err := m.db.MsgFilenames(m.key); err != nil {
|
||||
log.Errorf("failed to obtain filenames: %v", err)
|
||||
} else {
|
||||
info.Filenames = filenames
|
||||
// if size retrieval fails, just return info and log error
|
||||
if len(filenames) > 0 {
|
||||
if info.Size, err = lib.FileSize(filenames[0]); err != nil {
|
||||
log.Errorf("failed to obtain file size: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// NewBodyPartReader creates a new io.Reader for the requested body part(s) of
|
||||
// the message.
|
||||
func (m *Message) NewBodyPartReader(requestedParts []int) (io.Reader, error) {
|
||||
name, err := m.Filename()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
msg, err := rfc822.ReadMessage(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read message: %w", err)
|
||||
}
|
||||
return rfc822.FetchEntityPartReader(msg, requestedParts)
|
||||
}
|
||||
|
||||
// SetFlag adds or removes a flag from the message.
|
||||
// Notmuch doesn't support all the flags, and for those this errors.
|
||||
func (m *Message) SetFlag(flag models.Flags, enable bool) error {
|
||||
// Translate the flag into a notmuch tag, ignoring no-op flags.
|
||||
tag, ok := flagToTag[flag]
|
||||
if !ok {
|
||||
return fmt.Errorf("Notmuch doesn't support flag %v", flag)
|
||||
}
|
||||
|
||||
// Get the current state of the flag.
|
||||
// Note that notmuch handles some flags in an inverted sense
|
||||
oldState := false
|
||||
tags, err := m.Tags()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
oldState = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if flagToInvert[flag] {
|
||||
enable = !enable
|
||||
}
|
||||
|
||||
switch {
|
||||
case oldState == enable:
|
||||
return nil
|
||||
case enable:
|
||||
return m.AddTag(tag)
|
||||
default:
|
||||
return m.RemoveTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkAnswered either adds or removes the "replied" tag from the message.
|
||||
func (m *Message) MarkAnswered(answered bool) error {
|
||||
return m.SetFlag(models.AnsweredFlag, answered)
|
||||
}
|
||||
|
||||
// MarkForwarded either adds or removes the "forwarded" tag from the message.
|
||||
func (m *Message) MarkForwarded(forwarded bool) error {
|
||||
return m.SetFlag(models.ForwardedFlag, forwarded)
|
||||
}
|
||||
|
||||
// MarkRead either adds or removes the maildir.FlagSeen flag from the message.
|
||||
func (m *Message) MarkRead(seen bool) error {
|
||||
return m.SetFlag(models.SeenFlag, seen)
|
||||
}
|
||||
|
||||
// tags returns the notmuch tags of a message
|
||||
func (m *Message) Tags() ([]string, error) {
|
||||
return m.db.MsgTags(m.key)
|
||||
}
|
||||
|
||||
func (m *Message) Labels() ([]string, error) {
|
||||
return m.Tags()
|
||||
}
|
||||
|
||||
func (m *Message) ModelFlags() (models.Flags, error) {
|
||||
var flags models.Flags = models.SeenFlag
|
||||
tags, err := m.Tags()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, tag := range tags {
|
||||
flag := tagToFlag[tag]
|
||||
if flagToInvert[flag] {
|
||||
flags &^= flag
|
||||
} else {
|
||||
flags |= flag
|
||||
}
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
func (m *Message) UID() models.UID {
|
||||
return m.uid
|
||||
}
|
||||
|
||||
func (m *Message) Filename() (string, error) {
|
||||
return m.db.MsgFilename(m.key)
|
||||
}
|
||||
|
||||
// AddTag adds a single tag.
|
||||
// Consider using *Message.ModifyTags for multiple additions / removals
|
||||
// instead of looping over a tag array
|
||||
func (m *Message) AddTag(tag string) error {
|
||||
return m.ModifyTags([]string{tag}, nil)
|
||||
}
|
||||
|
||||
// RemoveTag removes a single tag.
|
||||
// Consider using *Message.ModifyTags for multiple additions / removals
|
||||
// instead of looping over a tag array
|
||||
func (m *Message) RemoveTag(tag string) error {
|
||||
return m.ModifyTags(nil, []string{tag})
|
||||
}
|
||||
|
||||
func (m *Message) ModifyTags(add, remove []string) error {
|
||||
return m.db.MsgModifyTags(m.key, add, remove)
|
||||
}
|
||||
|
||||
func (m *Message) Remove(curDir maildir.Dir, mfs types.MultiFileStrategy) error {
|
||||
rm, del, err := m.filenamesForStrategy(mfs, curDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rm = append(rm, del...)
|
||||
return m.deleteFiles(rm)
|
||||
}
|
||||
|
||||
func (m *Message) Copy(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error {
|
||||
cp, del, err := m.filenamesForStrategy(mfs, curDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filename := range cp {
|
||||
source, key := parseFilename(filename)
|
||||
if key == "" {
|
||||
return fmt.Errorf("failed to parse message filename: %s", filename)
|
||||
}
|
||||
|
||||
newKey, err := source.Copy(destDir, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newFilename, err := destDir.Filename(newKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = m.db.IndexFile(newFilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.deleteFiles(del)
|
||||
}
|
||||
|
||||
func (m *Message) Move(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error {
|
||||
move, del, err := m.filenamesForStrategy(mfs, curDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, filename := range move {
|
||||
// Remove encoded UID information from the key to prevent sync issues
|
||||
name := lib.StripUIDFromMessageFilename(filepath.Base(filename))
|
||||
dest := filepath.Join(string(destDir), "cur", name)
|
||||
|
||||
if err := os.Rename(filename, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = m.db.IndexFile(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.db.DeleteMessage(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.deleteFiles(del)
|
||||
}
|
||||
|
||||
func (m *Message) deleteFiles(filenames []string) error {
|
||||
for _, filename := range filenames {
|
||||
if err := os.Remove(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.db.DeleteMessage(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Message) filenamesForStrategy(strategy types.MultiFileStrategy,
|
||||
curDir maildir.Dir,
|
||||
) (act, del []string, err error) {
|
||||
filenames, err := m.db.MsgFilenames(m.key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return filterForStrategy(filenames, strategy, curDir)
|
||||
}
|
||||
|
||||
func filterForStrategy(filenames []string, strategy types.MultiFileStrategy,
|
||||
curDir maildir.Dir,
|
||||
) (act, del []string, err error) {
|
||||
if curDir == "" &&
|
||||
(strategy == types.ActDir || strategy == types.ActDirDelRest) {
|
||||
strategy = types.Refuse
|
||||
}
|
||||
|
||||
if len(filenames) < 2 {
|
||||
return filenames, []string{}, nil
|
||||
}
|
||||
|
||||
act = []string{}
|
||||
rest := []string{}
|
||||
switch strategy {
|
||||
case types.Refuse:
|
||||
return nil, nil, fmt.Errorf("refusing to act on multiple files")
|
||||
case types.ActAll:
|
||||
act = filenames
|
||||
case types.ActOne:
|
||||
fallthrough
|
||||
case types.ActOneDelRest:
|
||||
act = filenames[:1]
|
||||
rest = filenames[1:]
|
||||
case types.ActDir:
|
||||
fallthrough
|
||||
case types.ActDirDelRest:
|
||||
for _, filename := range filenames {
|
||||
if filepath.Dir(filepath.Dir(filename)) == string(curDir) {
|
||||
act = append(act, filename)
|
||||
} else {
|
||||
rest = append(rest, filename)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("invalid multi-file strategy %v", strategy)
|
||||
}
|
||||
|
||||
switch strategy {
|
||||
case types.ActOneDelRest:
|
||||
fallthrough
|
||||
case types.ActDirDelRest:
|
||||
del = rest
|
||||
default:
|
||||
del = []string{}
|
||||
}
|
||||
|
||||
return act, del, nil
|
||||
}
|
||||
|
||||
func parseFilename(filename string) (maildir.Dir, string) {
|
||||
base := filepath.Base(filename)
|
||||
dir := filepath.Dir(filename)
|
||||
dir, curdir := filepath.Split(dir)
|
||||
if curdir != "cur" {
|
||||
return "", ""
|
||||
}
|
||||
split := strings.Split(base, ":")
|
||||
if len(split) < 2 {
|
||||
return maildir.Dir(dir), ""
|
||||
}
|
||||
key := split[0]
|
||||
return maildir.Dir(dir), key
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package notmuch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-maildir"
|
||||
)
|
||||
|
||||
func TestFilterForStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
filenames []string
|
||||
strategy types.MultiFileStrategy
|
||||
curDir string
|
||||
expectedAct []string
|
||||
expectedDel []string
|
||||
expectedErr bool
|
||||
}{
|
||||
// if there's only one file, always act on it
|
||||
{
|
||||
filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
strategy: types.Refuse,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
strategy: types.ActAll,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
strategy: types.ActOne,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
strategy: types.ActOneDelRest,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
strategy: types.ActDir,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
strategy: types.ActDirDelRest,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
|
||||
// follow strategy for multiple files
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.Refuse,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActAll,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActOne,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActOneDelRest,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActDir,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActDirDelRest,
|
||||
curDir: "/h/j/m/B",
|
||||
expectedAct: []string{
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
},
|
||||
expectedDel: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
},
|
||||
|
||||
// refuse to act on multiple files for ActDir and friends if
|
||||
// no current dir is provided
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActDir,
|
||||
curDir: "",
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActDirDelRest,
|
||||
curDir: "",
|
||||
expectedErr: true,
|
||||
},
|
||||
|
||||
// act on multiple files w/o current dir for other strategies
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActAll,
|
||||
curDir: "",
|
||||
expectedAct: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActOne,
|
||||
curDir: "",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{},
|
||||
},
|
||||
{
|
||||
filenames: []string{
|
||||
"/h/j/m/A/cur/a.b.c:2,",
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
strategy: types.ActOneDelRest,
|
||||
curDir: "",
|
||||
expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"},
|
||||
expectedDel: []string{
|
||||
"/h/j/m/B/new/b.c.d",
|
||||
"/h/j/m/B/cur/c.d.e:2,S",
|
||||
"/h/j/m/C/new/d.e.f",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
act, del, err := filterForStrategy(test.filenames, test.strategy,
|
||||
maildir.Dir(test.curDir))
|
||||
|
||||
if test.expectedErr && err == nil {
|
||||
t.Errorf("[test %d] got nil, expected error", i)
|
||||
}
|
||||
|
||||
if !test.expectedErr && err != nil {
|
||||
t.Errorf("[test %d] got %v, expected nil", i, err)
|
||||
}
|
||||
|
||||
if !arrEq(act, test.expectedAct) {
|
||||
t.Errorf("[test %d] got %v, expected %v", i, act, test.expectedAct)
|
||||
}
|
||||
|
||||
if !arrEq(del, test.expectedDel) {
|
||||
t.Errorf("[test %d] got %v, expected %v", i, del, test.expectedDel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func arrEq(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package notmuch
|
||||
|
||||
import "git.sr.ht/~rjarry/aerc/models"
|
||||
|
||||
var tagToFlag = map[string]models.Flags{
|
||||
"unread": models.SeenFlag,
|
||||
"replied": models.AnsweredFlag,
|
||||
"passed": models.ForwardedFlag,
|
||||
"draft": models.DraftFlag,
|
||||
"flagged": models.FlaggedFlag,
|
||||
}
|
||||
|
||||
var flagToTag = map[models.Flags]string{
|
||||
models.SeenFlag: "unread",
|
||||
models.AnsweredFlag: "replied",
|
||||
models.ForwardedFlag: "passed",
|
||||
models.DraftFlag: "draft",
|
||||
models.FlaggedFlag: "flagged",
|
||||
}
|
||||
|
||||
var flagToInvert = map[models.Flags]bool{
|
||||
models.SeenFlag: true,
|
||||
models.AnsweredFlag: false,
|
||||
models.ForwardedFlag: false,
|
||||
models.DraftFlag: false,
|
||||
models.FlaggedFlag: false,
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build notmuch
|
||||
// +build notmuch
|
||||
|
||||
package notmuch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
type queryBuilder struct {
|
||||
s string
|
||||
}
|
||||
|
||||
func (q *queryBuilder) and(s string) {
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
if len(q.s) != 0 {
|
||||
q.s += " and "
|
||||
}
|
||||
q.s += "(" + s + ")"
|
||||
}
|
||||
|
||||
func (q *queryBuilder) or(s string) {
|
||||
if len(s) == 0 {
|
||||
return
|
||||
}
|
||||
if len(q.s) != 0 {
|
||||
q.s += " or "
|
||||
}
|
||||
q.s += "(" + s + ")"
|
||||
}
|
||||
|
||||
func translate(crit *types.SearchCriteria) string {
|
||||
if crit == nil {
|
||||
return ""
|
||||
}
|
||||
var base queryBuilder
|
||||
|
||||
// recipients
|
||||
var from queryBuilder
|
||||
for _, f := range crit.From {
|
||||
from.or("from:" + opt.QuoteArg(f))
|
||||
}
|
||||
if from.s != "" {
|
||||
base.and(from.s)
|
||||
}
|
||||
|
||||
var to queryBuilder
|
||||
for _, t := range crit.To {
|
||||
to.or("to:" + opt.QuoteArg(t))
|
||||
}
|
||||
if to.s != "" {
|
||||
base.and(to.s)
|
||||
}
|
||||
|
||||
var cc queryBuilder
|
||||
for _, c := range crit.Cc {
|
||||
cc.or("cc:" + opt.QuoteArg(c))
|
||||
}
|
||||
if cc.s != "" {
|
||||
base.and(cc.s)
|
||||
}
|
||||
|
||||
// flags
|
||||
for f := range flagToTag {
|
||||
if crit.WithFlags.Has(f) {
|
||||
base.and(getParsedFlag(f, false))
|
||||
}
|
||||
if crit.WithoutFlags.Has(f) {
|
||||
base.and(getParsedFlag(f, true))
|
||||
}
|
||||
}
|
||||
|
||||
// dates
|
||||
switch {
|
||||
case !crit.StartDate.IsZero() && !crit.EndDate.IsZero():
|
||||
base.and(fmt.Sprintf("date:@%d..@%d",
|
||||
crit.StartDate.Unix(), crit.EndDate.Unix()))
|
||||
case !crit.StartDate.IsZero():
|
||||
base.and(fmt.Sprintf("date:@%d..", crit.StartDate.Unix()))
|
||||
case !crit.EndDate.IsZero():
|
||||
base.and(fmt.Sprintf("date:..@%d", crit.EndDate.Unix()))
|
||||
}
|
||||
|
||||
// other terms
|
||||
if len(crit.Terms) > 0 {
|
||||
if crit.SearchBody {
|
||||
base.and("body:" + opt.QuoteArg(strings.Join(crit.Terms, " ")))
|
||||
} else {
|
||||
for _, term := range crit.Terms {
|
||||
base.and(term)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base.s
|
||||
}
|
||||
|
||||
func getParsedFlag(flag models.Flags, inverse bool) string {
|
||||
name := "tag:" + flagToTag[flag]
|
||||
if flagToInvert[flag] {
|
||||
name = "not " + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type WorkerMessage interface {
|
||||
InResponseTo() WorkerMessage
|
||||
getId() int64
|
||||
setId(id int64)
|
||||
Account() string
|
||||
setAccount(string)
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
inResponseTo WorkerMessage
|
||||
id int64
|
||||
acct string
|
||||
}
|
||||
|
||||
func RespondTo(msg WorkerMessage) Message {
|
||||
return Message{
|
||||
inResponseTo: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Message) InResponseTo() WorkerMessage {
|
||||
return m.inResponseTo
|
||||
}
|
||||
|
||||
func (m Message) getId() int64 {
|
||||
return m.id
|
||||
}
|
||||
|
||||
func (m *Message) setId(id int64) {
|
||||
m.id = id
|
||||
}
|
||||
|
||||
func (m *Message) Account() string {
|
||||
return m.acct
|
||||
}
|
||||
|
||||
func (m *Message) setAccount(name string) {
|
||||
m.acct = name
|
||||
}
|
||||
|
||||
// Meta-messages
|
||||
|
||||
type Done struct {
|
||||
Message
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Message
|
||||
Error error
|
||||
}
|
||||
|
||||
type Cancelled struct {
|
||||
Message
|
||||
}
|
||||
|
||||
type ConnError struct {
|
||||
Message
|
||||
Error error
|
||||
}
|
||||
|
||||
type Unsupported struct {
|
||||
Message
|
||||
}
|
||||
|
||||
// Actions
|
||||
|
||||
type Configure struct {
|
||||
Message
|
||||
Config *config.AccountConfig
|
||||
}
|
||||
|
||||
type Connect struct {
|
||||
Message
|
||||
}
|
||||
|
||||
type Reconnect struct {
|
||||
Message
|
||||
}
|
||||
|
||||
type Disconnect struct {
|
||||
Message
|
||||
}
|
||||
|
||||
type ListDirectories struct {
|
||||
Message
|
||||
}
|
||||
|
||||
type OpenDirectory struct {
|
||||
Message
|
||||
Context context.Context
|
||||
Directory string
|
||||
Query string
|
||||
Force bool
|
||||
}
|
||||
|
||||
type FetchDirectoryContents struct {
|
||||
Message
|
||||
Context context.Context
|
||||
SortCriteria []*SortCriterion
|
||||
Filter *SearchCriteria
|
||||
}
|
||||
|
||||
type FetchDirectoryThreaded struct {
|
||||
Message
|
||||
Context context.Context
|
||||
SortCriteria []*SortCriterion
|
||||
Filter *SearchCriteria
|
||||
ThreadContext bool
|
||||
}
|
||||
|
||||
type SearchDirectory struct {
|
||||
Message
|
||||
Context context.Context
|
||||
Criteria *SearchCriteria
|
||||
}
|
||||
|
||||
type DirectoryThreaded struct {
|
||||
Message
|
||||
Threads []*Thread
|
||||
}
|
||||
|
||||
type CreateDirectory struct {
|
||||
Message
|
||||
Directory string
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
type RemoveDirectory struct {
|
||||
Message
|
||||
Directory string
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
type FetchMessageHeaders struct {
|
||||
Message
|
||||
Context context.Context
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type FetchFullMessages struct {
|
||||
Message
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type FetchMessageBodyPart struct {
|
||||
Message
|
||||
Uid models.UID
|
||||
Part []int
|
||||
}
|
||||
|
||||
type FetchMessageFlags struct {
|
||||
Message
|
||||
Context context.Context
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type DeleteMessages struct {
|
||||
Message
|
||||
Uids []models.UID
|
||||
MultiFileStrategy *MultiFileStrategy
|
||||
}
|
||||
|
||||
// Flag messages with different mail types
|
||||
type FlagMessages struct {
|
||||
Message
|
||||
Enable bool
|
||||
Flags models.Flags
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type AnsweredMessages struct {
|
||||
Message
|
||||
Answered bool
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type ForwardedMessages struct {
|
||||
Message
|
||||
Forwarded bool
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type CopyMessages struct {
|
||||
Message
|
||||
Destination string
|
||||
Uids []models.UID
|
||||
MultiFileStrategy *MultiFileStrategy
|
||||
}
|
||||
|
||||
type MoveMessages struct {
|
||||
Message
|
||||
Destination string
|
||||
Uids []models.UID
|
||||
MultiFileStrategy *MultiFileStrategy
|
||||
}
|
||||
|
||||
type AppendMessage struct {
|
||||
Message
|
||||
Destination string
|
||||
Flags models.Flags
|
||||
Date time.Time
|
||||
Reader io.Reader
|
||||
Length int
|
||||
}
|
||||
|
||||
type CheckMail struct {
|
||||
Message
|
||||
Directories []string
|
||||
Command string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type StartSendingMessage struct {
|
||||
Message
|
||||
From *mail.Address
|
||||
Rcpts []*mail.Address
|
||||
CopyTo []string
|
||||
}
|
||||
|
||||
// Messages
|
||||
|
||||
type Directory struct {
|
||||
Message
|
||||
Dir *models.Directory
|
||||
}
|
||||
|
||||
type DirectoryInfo struct {
|
||||
Message
|
||||
Info *models.DirectoryInfo
|
||||
Refetch bool
|
||||
}
|
||||
|
||||
type DirectoryContents struct {
|
||||
Message
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type SearchResults struct {
|
||||
Message
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type MessageInfo struct {
|
||||
Message
|
||||
Info *models.MessageInfo
|
||||
NeedsFlags bool
|
||||
}
|
||||
|
||||
type FullMessage struct {
|
||||
Message
|
||||
Content *models.FullMessage
|
||||
}
|
||||
|
||||
type MessageBodyPart struct {
|
||||
Message
|
||||
Part *models.MessageBodyPart
|
||||
}
|
||||
|
||||
type MessagesDeleted struct {
|
||||
Message
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type MessagesCopied struct {
|
||||
Message
|
||||
Destination string
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type MessagesMoved struct {
|
||||
Message
|
||||
Destination string
|
||||
Uids []models.UID
|
||||
}
|
||||
|
||||
type ModifyLabels struct {
|
||||
Message
|
||||
Uids []models.UID
|
||||
Add []string
|
||||
Remove []string
|
||||
}
|
||||
|
||||
type LabelList struct {
|
||||
Message
|
||||
Labels []string
|
||||
}
|
||||
|
||||
type CheckMailDirectories struct {
|
||||
Message
|
||||
Directories []string
|
||||
}
|
||||
|
||||
type MessageWriter struct {
|
||||
Message
|
||||
Writer io.WriteCloser
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package types
|
||||
|
||||
// MultiFileStrategy represents a strategy for taking file-based actions (e.g.,
|
||||
// move, copy, delete) on messages that are represented by more than one file.
|
||||
// These strategies are only used by the notmuch backend but are defined in this
|
||||
// package to prevent import cycles.
|
||||
type MultiFileStrategy uint
|
||||
|
||||
const (
|
||||
Refuse MultiFileStrategy = iota
|
||||
ActAll
|
||||
ActOne
|
||||
ActOneDelRest
|
||||
ActDir
|
||||
ActDirDelRest
|
||||
)
|
||||
|
||||
var StrToStrategy = map[string]MultiFileStrategy{
|
||||
"refuse": Refuse,
|
||||
"act-all": ActAll,
|
||||
"act-one": ActOne,
|
||||
"act-one-delete-rest": ActOneDelRest,
|
||||
"act-dir": ActDir,
|
||||
"act-dir-delete-rest": ActDirDelRest,
|
||||
}
|
||||
|
||||
func StrategyStrs() []string {
|
||||
strs := make([]string, 0, len(StrToStrategy))
|
||||
for s := range StrToStrategy {
|
||||
strs = append(strs, s)
|
||||
}
|
||||
return strs
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"net/textproto"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type SearchCriteria struct {
|
||||
WithFlags models.Flags
|
||||
WithoutFlags models.Flags
|
||||
From []string
|
||||
To []string
|
||||
Cc []string
|
||||
Headers textproto.MIMEHeader
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
SearchBody bool
|
||||
SearchAll bool
|
||||
Terms []string
|
||||
UseExtension bool
|
||||
}
|
||||
|
||||
func (c *SearchCriteria) PrepareHeader() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if c.Headers == nil {
|
||||
c.Headers = make(textproto.MIMEHeader)
|
||||
}
|
||||
for _, from := range c.From {
|
||||
c.Headers.Add("From", from)
|
||||
}
|
||||
for _, to := range c.To {
|
||||
c.Headers.Add("To", to)
|
||||
}
|
||||
for _, cc := range c.Cc {
|
||||
c.Headers.Add("Cc", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SearchCriteria) Combine(other *SearchCriteria) *SearchCriteria {
|
||||
if c == nil {
|
||||
return other
|
||||
}
|
||||
headers := make(textproto.MIMEHeader)
|
||||
for k, v := range c.Headers {
|
||||
headers[k] = v
|
||||
}
|
||||
for k, v := range other.Headers {
|
||||
headers[k] = v
|
||||
}
|
||||
start := c.StartDate
|
||||
if !other.StartDate.IsZero() {
|
||||
start = other.StartDate
|
||||
}
|
||||
end := c.EndDate
|
||||
if !other.EndDate.IsZero() {
|
||||
end = other.EndDate
|
||||
}
|
||||
from := make([]string, len(c.From)+len(other.From))
|
||||
copy(from[:len(c.From)], c.From)
|
||||
copy(from[len(c.From):], other.From)
|
||||
to := make([]string, len(c.To)+len(other.To))
|
||||
copy(to[:len(c.To)], c.To)
|
||||
copy(to[len(c.To):], other.To)
|
||||
cc := make([]string, len(c.Cc)+len(other.Cc))
|
||||
copy(cc[:len(c.Cc)], c.Cc)
|
||||
copy(cc[len(c.Cc):], other.Cc)
|
||||
return &SearchCriteria{
|
||||
WithFlags: c.WithFlags | other.WithFlags,
|
||||
WithoutFlags: c.WithoutFlags | other.WithoutFlags,
|
||||
From: from,
|
||||
To: to,
|
||||
Cc: cc,
|
||||
Headers: headers,
|
||||
StartDate: start,
|
||||
EndDate: end,
|
||||
SearchBody: c.SearchBody || other.SearchBody,
|
||||
SearchAll: c.SearchAll || other.SearchAll,
|
||||
Terms: append(c.Terms, other.Terms...),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package types
|
||||
|
||||
type SortField int
|
||||
|
||||
const (
|
||||
SortArrival SortField = iota
|
||||
SortCc
|
||||
SortDate
|
||||
SortFrom
|
||||
SortRead
|
||||
SortSize
|
||||
SortSubject
|
||||
SortTo
|
||||
SortFlagged
|
||||
)
|
||||
|
||||
type SortCriterion struct {
|
||||
Field SortField
|
||||
Reverse bool
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type Thread struct {
|
||||
Uid models.UID
|
||||
Parent *Thread
|
||||
PrevSibling *Thread
|
||||
NextSibling *Thread
|
||||
FirstChild *Thread
|
||||
|
||||
Hidden int // if this flag is not zero the message isn't rendered in the UI
|
||||
Deleted bool // if this flag is set the message was deleted
|
||||
|
||||
// if this flag is set the message is the root of an incomplete thread
|
||||
Dummy bool
|
||||
|
||||
// Context indicates the message doesn't match the mailbox / query but
|
||||
// is displayed for context
|
||||
Context bool
|
||||
}
|
||||
|
||||
// AddChild appends the child node at the end of the existing children of t.
|
||||
func (t *Thread) AddChild(child *Thread) {
|
||||
t.InsertCmp(child, func(_, _ *Thread) bool { return true })
|
||||
}
|
||||
|
||||
// OrderedInsert inserts the child node in ascending order among the existing
|
||||
// children based on their respective UIDs.
|
||||
func (t *Thread) OrderedInsert(child *Thread) {
|
||||
t.InsertCmp(child, func(child, iter *Thread) bool { return child.Uid > iter.Uid })
|
||||
}
|
||||
|
||||
// InsertCmp inserts child as a child node into t in ascending order. The
|
||||
// ascending order is determined by the bigger function that compares the child
|
||||
// with the existing children. It should return true when the child is bigger
|
||||
// than the other, and false otherwise.
|
||||
func (t *Thread) InsertCmp(child *Thread, bigger func(*Thread, *Thread) bool) {
|
||||
if t.FirstChild == nil {
|
||||
t.FirstChild = child
|
||||
} else {
|
||||
start := &Thread{NextSibling: t.FirstChild}
|
||||
var iter *Thread
|
||||
for iter = start; iter.NextSibling != nil &&
|
||||
bigger(child, iter.NextSibling); iter = iter.NextSibling {
|
||||
}
|
||||
child.NextSibling = iter.NextSibling
|
||||
iter.NextSibling = child
|
||||
t.FirstChild = start.NextSibling
|
||||
}
|
||||
child.Parent = t
|
||||
}
|
||||
|
||||
func (t *Thread) Walk(walkFn NewThreadWalkFn) error {
|
||||
err := newWalk(t, walkFn, 0, nil)
|
||||
if errors.Is(err, ErrSkipThread) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Root returns the root thread of the thread tree
|
||||
func (t *Thread) Root() *Thread {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
var iter *Thread
|
||||
for iter = t; iter.Parent != nil; iter = iter.Parent {
|
||||
}
|
||||
return iter
|
||||
}
|
||||
|
||||
// Uids returns all associated uids for the given thread and its children
|
||||
func (t *Thread) Uids() []models.UID {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
uids := make([]models.UID, 0)
|
||||
err := t.Walk(func(node *Thread, _ int, _ error) error {
|
||||
uids = append(uids, node.Uid)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("walk to collect uids failed: %v", err)
|
||||
}
|
||||
return uids
|
||||
}
|
||||
|
||||
func (t *Thread) String() string {
|
||||
if t == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
var parent models.UID
|
||||
if t.Parent != nil {
|
||||
parent = t.Parent.Uid
|
||||
}
|
||||
var next models.UID
|
||||
if t.NextSibling != nil {
|
||||
next = t.NextSibling.Uid
|
||||
}
|
||||
var child models.UID
|
||||
if t.FirstChild != nil {
|
||||
child = t.FirstChild.Uid
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"[%s] (parent:%s, next:%s, child:%s)",
|
||||
t.Uid, parent, next, child,
|
||||
)
|
||||
}
|
||||
|
||||
func newWalk(node *Thread, walkFn NewThreadWalkFn, lvl int, ce error) error {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
err := walkFn(node, lvl, ce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
err = newWalk(child, walkFn, lvl+1, err)
|
||||
if errors.Is(err, ErrSkipThread) {
|
||||
err = nil
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrSkipThread = errors.New("skip this Thread")
|
||||
|
||||
type NewThreadWalkFn func(t *Thread, level int, currentErr error) error
|
||||
|
||||
// Implement interface to be able to sort threads by newest (max UID)
|
||||
type ByUID []*Thread
|
||||
|
||||
func getMaxUID(thread *Thread) models.UID {
|
||||
// TODO: should we make this part of the Thread type to avoid recomputation?
|
||||
var Uid models.UID
|
||||
|
||||
_ = thread.Walk(func(t *Thread, _ int, currentErr error) error {
|
||||
if t.Deleted || t.Hidden > 0 {
|
||||
return nil
|
||||
}
|
||||
if t.Uid > Uid {
|
||||
Uid = t.Uid
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return Uid
|
||||
}
|
||||
|
||||
func (s ByUID) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s ByUID) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
func (s ByUID) Less(i, j int) bool {
|
||||
maxUID_i := getMaxUID(s[i])
|
||||
maxUID_j := getMaxUID(s[j])
|
||||
return maxUID_i < maxUID_j
|
||||
}
|
||||
|
||||
func SortThreadsBy(toSort []*Thread, sortBy []models.UID) {
|
||||
// build a map from sortBy
|
||||
uidMap := make(map[models.UID]int)
|
||||
for i, uid := range sortBy {
|
||||
uidMap[uid] = i
|
||||
}
|
||||
// sortslice of toSort with less function of indexing the map sortBy
|
||||
sort.Slice(toSort, func(i, j int) bool {
|
||||
return uidMap[getMaxUID(toSort[i])] < uidMap[getMaxUID(toSort[j])]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
func genFakeTree() *Thread {
|
||||
tree := new(Thread)
|
||||
var prevChild *Thread
|
||||
for i := uint32(1); i < uint32(3); i++ {
|
||||
child := &Thread{
|
||||
Uid: models.Uint32ToUid(i * 10),
|
||||
Parent: tree,
|
||||
PrevSibling: prevChild,
|
||||
}
|
||||
if prevChild != nil {
|
||||
prevChild.NextSibling = child
|
||||
} else if tree.FirstChild == nil {
|
||||
tree.FirstChild = child
|
||||
} else {
|
||||
panic("unreachable")
|
||||
}
|
||||
prevChild = child
|
||||
var prevSecond *Thread
|
||||
for j := uint32(1); j < uint32(3); j++ {
|
||||
second := &Thread{
|
||||
Uid: models.Uint32ToUid(models.UidToUint32(child.Uid) + j),
|
||||
Parent: child,
|
||||
PrevSibling: prevSecond,
|
||||
}
|
||||
if prevSecond != nil {
|
||||
prevSecond.NextSibling = second
|
||||
} else if child.FirstChild == nil {
|
||||
child.FirstChild = second
|
||||
} else {
|
||||
panic("unreachable")
|
||||
}
|
||||
prevSecond = second
|
||||
var prevThird *Thread
|
||||
limit := uint32(3)
|
||||
if j == 2 {
|
||||
limit = 8
|
||||
}
|
||||
for k := uint32(1); k < limit; k++ {
|
||||
third := &Thread{
|
||||
Uid: models.Uint32ToUid(models.UidToUint32(second.Uid)*10 + j),
|
||||
Parent: second,
|
||||
PrevSibling: prevThird,
|
||||
}
|
||||
if prevThird != nil {
|
||||
prevThird.NextSibling = third
|
||||
} else if second.FirstChild == nil {
|
||||
second.FirstChild = third
|
||||
} else {
|
||||
panic("unreachable")
|
||||
}
|
||||
prevThird = third
|
||||
}
|
||||
}
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
func TestNewWalk(t *testing.T) {
|
||||
tree := genFakeTree()
|
||||
var prefix []string
|
||||
lastLevel := 0
|
||||
tree.Walk(func(t *Thread, lvl int, e error) error {
|
||||
if e != nil {
|
||||
fmt.Printf("ERROR: %v\n", e)
|
||||
}
|
||||
if lvl > lastLevel && lvl > 1 {
|
||||
// we actually just descended... so figure out what connector we need
|
||||
// level 1 is flush to the root, so we avoid the indentation there
|
||||
if t.Parent.NextSibling != nil {
|
||||
prefix = append(prefix, "│ ")
|
||||
} else {
|
||||
prefix = append(prefix, " ")
|
||||
}
|
||||
} else if lvl < lastLevel {
|
||||
// ascended, need to trim the prefix layers
|
||||
diff := lastLevel - lvl
|
||||
prefix = prefix[:len(prefix)-diff]
|
||||
}
|
||||
|
||||
var arrow string
|
||||
if t.Parent != nil {
|
||||
if t.NextSibling != nil {
|
||||
arrow = "├─>"
|
||||
} else {
|
||||
arrow = "└─>"
|
||||
}
|
||||
}
|
||||
|
||||
// format
|
||||
fmt.Printf("%s%s%s\n", strings.Join(prefix, ""), arrow, t)
|
||||
|
||||
lastLevel = lvl
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func uidSeq(tree *Thread) string {
|
||||
var seq []string
|
||||
tree.Walk(func(t *Thread, _ int, _ error) error {
|
||||
seq = append(seq, string(t.Uid))
|
||||
return nil
|
||||
})
|
||||
return strings.Join(seq, ".")
|
||||
}
|
||||
|
||||
func TestThread_AddChild(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seq []models.UID
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ascending",
|
||||
seq: []models.UID{"1", "2", "3", "4", "5", "6"},
|
||||
want: ".1.2.3.4.5.6",
|
||||
},
|
||||
{
|
||||
name: "descending",
|
||||
seq: []models.UID{"6", "5", "4", "3", "2", "1"},
|
||||
want: ".6.5.4.3.2.1",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tree := new(Thread)
|
||||
for _, i := range test.seq {
|
||||
tree.AddChild(&Thread{Uid: i})
|
||||
}
|
||||
if got := uidSeq(tree); got != test.want {
|
||||
t.Errorf("got: %s, but wanted: %s", got,
|
||||
test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThread_OrderedInsert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seq []models.UID
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ascending",
|
||||
seq: []models.UID{"1", "2", "3", "4", "5", "6"},
|
||||
want: ".1.2.3.4.5.6",
|
||||
},
|
||||
{
|
||||
name: "descending",
|
||||
seq: []models.UID{"6", "5", "4", "3", "2", "1"},
|
||||
want: ".1.2.3.4.5.6",
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
seq: []models.UID{"2", "1", "6", "3", "4", "5"},
|
||||
want: ".1.2.3.4.5.6",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tree := new(Thread)
|
||||
for _, i := range test.seq {
|
||||
tree.OrderedInsert(&Thread{Uid: i})
|
||||
}
|
||||
if got := uidSeq(tree); got != test.want {
|
||||
t.Errorf("got: %s, but wanted: %s", got,
|
||||
test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThread_InsertCmd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seq []models.UID
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ascending",
|
||||
seq: []models.UID{"1", "2", "3", "4", "5", "6"},
|
||||
want: ".6.4.2.1.3.5",
|
||||
},
|
||||
{
|
||||
name: "descending",
|
||||
seq: []models.UID{"6", "5", "4", "3", "2", "1"},
|
||||
want: ".6.4.2.1.3.5",
|
||||
},
|
||||
{
|
||||
name: "mixed",
|
||||
seq: []models.UID{"2", "1", "6", "3", "4", "5"},
|
||||
want: ".6.4.2.1.3.5",
|
||||
},
|
||||
}
|
||||
sortMap := map[models.UID]int{
|
||||
"6": 1,
|
||||
"4": 2,
|
||||
"2": 3,
|
||||
"1": 4,
|
||||
"3": 5,
|
||||
"5": 6,
|
||||
}
|
||||
|
||||
// bigger compares the new child with the next node and returns true if
|
||||
// the child node is bigger and false otherwise.
|
||||
bigger := func(newNode, nextChild *Thread) bool {
|
||||
return sortMap[newNode.Uid] > sortMap[nextChild.Uid]
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tree := new(Thread)
|
||||
for _, i := range test.seq {
|
||||
tree.InsertCmp(&Thread{Uid: i}, bigger)
|
||||
}
|
||||
if got := uidSeq(tree); got != test.want {
|
||||
t.Errorf("got: %s, but wanted: %s", got,
|
||||
test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type WorkerInteractor interface {
|
||||
log.Logger
|
||||
Actions() chan WorkerMessage
|
||||
ProcessAction(WorkerMessage) WorkerMessage
|
||||
PostAction(WorkerMessage, func(msg WorkerMessage))
|
||||
PostMessage(WorkerMessage, func(msg WorkerMessage))
|
||||
Unwrap() WorkerInteractor
|
||||
}
|
||||
|
||||
var lastId int64 = 1 // access via atomic
|
||||
|
||||
type Backend interface {
|
||||
Run()
|
||||
Capabilities() *models.Capabilities
|
||||
PathSeparator() string
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
Backend Backend
|
||||
|
||||
actions chan WorkerMessage
|
||||
actionCallbacks map[int64]func(msg WorkerMessage)
|
||||
messageCallbacks map[int64]func(msg WorkerMessage)
|
||||
actionQueue *list.List
|
||||
status int32
|
||||
name string
|
||||
|
||||
sync.Mutex
|
||||
log.Logger
|
||||
}
|
||||
|
||||
func NewWorker(name string) *Worker {
|
||||
return &Worker{
|
||||
Logger: log.NewLogger(name, 2),
|
||||
actions: make(chan WorkerMessage),
|
||||
actionCallbacks: make(map[int64]func(msg WorkerMessage)),
|
||||
messageCallbacks: make(map[int64]func(msg WorkerMessage)),
|
||||
actionQueue: list.New(),
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) Unwrap() WorkerInteractor {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (worker *Worker) Actions() chan WorkerMessage {
|
||||
return worker.actions
|
||||
}
|
||||
|
||||
func (worker *Worker) setId(msg WorkerMessage) {
|
||||
id := atomic.AddInt64(&lastId, 1)
|
||||
msg.setId(id)
|
||||
}
|
||||
|
||||
const (
|
||||
idle int32 = iota
|
||||
busy
|
||||
)
|
||||
|
||||
// Add a new task to the action queue without blocking. Start processing the
|
||||
// queue in the background if needed.
|
||||
func (worker *Worker) queue(msg WorkerMessage) {
|
||||
worker.Lock()
|
||||
defer worker.Unlock()
|
||||
worker.actionQueue.PushBack(msg)
|
||||
if atomic.LoadInt32(&worker.status) == idle {
|
||||
atomic.StoreInt32(&worker.status, busy)
|
||||
go worker.processQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Start processing the action queue and write all messages to the actions
|
||||
// channel, one by one. Stop when the action queue is empty.
|
||||
func (worker *Worker) processQueue() {
|
||||
defer log.PanicHandler()
|
||||
for {
|
||||
worker.Lock()
|
||||
e := worker.actionQueue.Front()
|
||||
if e == nil {
|
||||
atomic.StoreInt32(&worker.status, idle)
|
||||
worker.Unlock()
|
||||
return
|
||||
}
|
||||
msg := worker.actionQueue.Remove(e).(WorkerMessage)
|
||||
worker.Unlock()
|
||||
worker.actions <- msg
|
||||
}
|
||||
}
|
||||
|
||||
// PostAction posts an action to the worker. This method should not be called
|
||||
// from the same goroutine that the worker runs in or deadlocks may occur
|
||||
func (worker *Worker) PostAction(msg WorkerMessage, cb func(msg WorkerMessage)) {
|
||||
worker.setId(msg)
|
||||
// write to actions channel without blocking
|
||||
worker.queue(msg)
|
||||
|
||||
if cb != nil {
|
||||
worker.Lock()
|
||||
worker.actionCallbacks[msg.getId()] = cb
|
||||
worker.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
var WorkerMessages = make(chan WorkerMessage, 50)
|
||||
|
||||
// PostMessage posts an message to the UI. This method should not be called
|
||||
// from the same goroutine that the UI runs in or deadlocks may occur
|
||||
func (worker *Worker) PostMessage(msg WorkerMessage,
|
||||
cb func(msg WorkerMessage),
|
||||
) {
|
||||
worker.setId(msg)
|
||||
msg.setAccount(worker.name)
|
||||
|
||||
WorkerMessages <- msg
|
||||
|
||||
if cb != nil {
|
||||
worker.Lock()
|
||||
worker.messageCallbacks[msg.getId()] = cb
|
||||
worker.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) ProcessMessage(msg WorkerMessage) WorkerMessage {
|
||||
if inResponseTo := msg.InResponseTo(); inResponseTo != nil {
|
||||
worker.Lock()
|
||||
f, ok := worker.actionCallbacks[inResponseTo.getId()]
|
||||
worker.Unlock()
|
||||
if ok {
|
||||
f(msg)
|
||||
switch msg.(type) {
|
||||
case *Cancelled, *Done:
|
||||
worker.Lock()
|
||||
delete(worker.actionCallbacks, inResponseTo.getId())
|
||||
worker.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (worker *Worker) ProcessAction(msg WorkerMessage) WorkerMessage {
|
||||
if inResponseTo := msg.InResponseTo(); inResponseTo != nil {
|
||||
worker.Lock()
|
||||
f, ok := worker.messageCallbacks[inResponseTo.getId()]
|
||||
worker.Unlock()
|
||||
if ok {
|
||||
f(msg)
|
||||
if _, ok := msg.(*Done); ok {
|
||||
worker.Lock()
|
||||
delete(worker.messageCallbacks, inResponseTo.getId())
|
||||
worker.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (worker *Worker) PathSeparator() string {
|
||||
return worker.Backend.PathSeparator()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/worker/handlers"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
// Guesses the appropriate worker type based on the given source string
|
||||
func NewWorker(source string, name string) (*types.Worker, error) {
|
||||
u, err := url.Parse(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worker := types.NewWorker(name)
|
||||
scheme := u.Scheme
|
||||
if strings.ContainsRune(scheme, '+') {
|
||||
scheme = scheme[:strings.IndexRune(scheme, '+')]
|
||||
}
|
||||
backend, err := handlers.GetHandlerForScheme(scheme, worker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worker.Backend = backend
|
||||
return worker, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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/maildir"
|
||||
_ "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||
)
|
||||
Reference in New Issue
Block a user