init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
//go:build notmuch
// +build notmuch
package worker
import _ "git.sr.ht/~rjarry/aerc/worker/notmuch"
+27
View File
@@ -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
}
+212
View File
@@ -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()
}
+76
View File
@@ -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)
}
+187
View File
@@ -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
}
+196
View File
@@ -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
}
+19
View File
@@ -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)
}
}
+149
View File
@@ -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
}
+101
View File
@@ -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()
}
+74
View File
@@ -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)
}
}
}
+46
View File
@@ -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",
}
+292
View File
@@ -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)
}
+156
View File
@@ -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)
}
+132
View File
@@ -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
}
}
+119
View File
@@ -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
}
+51
View File
@@ -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))
}
+144
View File
@@ -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)
}
+68
View File
@@ -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)
}
}
+149
View File
@@ -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)
}
+203
View File
@@ -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
}
+19
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+77
View File
@@ -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]
})
}
+85
View File
@@ -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())
}
+397
View File
@@ -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
}
+45
View File
@@ -0,0 +1,45 @@
package cache
import (
"os"
"path"
"git.sr.ht/~rockorager/go-jmap"
)
func (c *JMAPCache) GetBlob(id jmap.ID) ([]byte, error) {
fpath := c.blobPath(id)
if fpath == "" {
return nil, notfound
}
return os.ReadFile(fpath)
}
func (c *JMAPCache) PutBlob(id jmap.ID, buf []byte) error {
fpath := c.blobPath(id)
if fpath == "" {
return nil
}
_ = os.MkdirAll(path.Dir(fpath), 0o700)
return os.WriteFile(fpath, buf, 0o600)
}
func (c *JMAPCache) DeleteBlob(id jmap.ID) error {
fpath := c.blobPath(id)
if fpath == "" {
return nil
}
defer func() {
_ = os.Remove(path.Dir(fpath))
}()
return os.Remove(fpath)
}
func (c *JMAPCache) blobPath(id jmap.ID) string {
if c.blobsDir == "" || id == "" {
return ""
}
name := string(id)
sub := name[len(name)-2:]
return path.Join(c.blobsDir, sub, name)
}
+109
View File
@@ -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")
}
+40
View File
@@ -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)
}
+59
View File
@@ -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)
}
+33
View File
@@ -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)
}
+35
View File
@@ -0,0 +1,35 @@
package cache
import (
"git.sr.ht/~rockorager/go-jmap"
"git.sr.ht/~rockorager/go-jmap/mail/mailbox"
)
func (c *JMAPCache) GetMailbox(id jmap.ID) (*mailbox.Mailbox, error) {
buf, err := c.get(mailboxKey(id))
if err != nil {
return nil, err
}
m := new(mailbox.Mailbox)
err = unmarshal(buf, m)
if err != nil {
return nil, err
}
return m, nil
}
func (c *JMAPCache) PutMailbox(id jmap.ID, m *mailbox.Mailbox) error {
buf, err := marshal(m)
if err != nil {
return err
}
return c.put(mailboxKey(id), buf)
}
func (c *JMAPCache) DeleteMailbox(id jmap.ID) error {
return c.delete(mailboxKey(id))
}
func mailboxKey(id jmap.ID) string {
return "mailbox/" + string(id)
}
+32
View File
@@ -0,0 +1,32 @@
package cache
import (
"git.sr.ht/~rockorager/go-jmap"
)
type IDList struct {
IDs []jmap.ID
}
func (c *JMAPCache) GetMailboxList() ([]jmap.ID, error) {
buf, err := c.get(mailboxListKey)
if err != nil {
return nil, err
}
var list IDList
err = unmarshal(buf, &list)
if err != nil {
return nil, err
}
return list.IDs, nil
}
func (c *JMAPCache) PutMailboxList(list []jmap.ID) error {
buf, err := marshal(&IDList{IDs: list})
if err != nil {
return err
}
return c.put(mailboxListKey, buf)
}
const mailboxListKey = "mailbox/list"
+34
View File
@@ -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"
+43
View File
@@ -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"
)
+34
View File
@@ -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)
}
+66
View File
@@ -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
}
+146
View File
@@ -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
}
+345
View File
@@ -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
}
+220
View File
@@ -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)
}
+179
View File
@@ -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)
}
+484
View File
@@ -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
}
+101
View File
@@ -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
}
+158
View File
@@ -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)
}
+263
View File
@@ -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
}
+63
View File
@@ -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
}
+197
View File
@@ -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)
}
}
}
}
+34
View File
@@ -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
}
+54
View File
@@ -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)
}
}
}
+29
View File
@@ -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
}
+152
View File
@@ -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
}
+201
View File
@@ -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
}
+15
View File
@@ -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
}
+149
View File
@@ -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)
}
}
+149
View File
@@ -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)
}
+144
View File
@@ -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
}
+67
View File
@@ -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)
}
+984
View File
@@ -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)
}
}
}
+59
View File
@@ -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
}
+49
View File
@@ -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()
}
+185
View File
@@ -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
}
+474
View File
@@ -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
}
+179
View File
@@ -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
}
+103
View File
@@ -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)
}
}
}
+133
View File
@@ -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)
}
+92
View File
@@ -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
}
+372
View File
@@ -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 := &notmuch.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)
}
+334
View File
@@ -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
}
+264
View File
@@ -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
}
+30
View File
@@ -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,
}
+111
View File
@@ -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
+308
View File
@@ -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
}
+33
View File
@@ -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
}
+84
View File
@@ -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...),
}
}
+20
View File
@@ -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
}
+185
View File
@@ -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])]
})
}
+232
View File
@@ -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)
}
})
}
}
+172
View File
@@ -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()
}
+28
View File
@@ -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
}
+9
View File
@@ -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"
)