package imap import ( "bufio" "bytes" "encoding/gob" "errors" "fmt" "os" "path" "strings" "time" "git.sr.ht/~rjarry/aerc/lib/parse" "git.sr.ht/~rjarry/aerc/log" "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/mitchellh/go-homedir" "github.com/syndtr/goleveldb/leveldb" ) type CachedHeader struct { BodyStructure models.BodyStructure Envelope models.Envelope InternalDate time.Time Uid uint32 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("0001") 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...) } cd, err := cacheDir() if err != nil { w.cache = nil w.worker.Errorf("unable to find cache directory: %v", err) return } p := path.Join(cd, 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) { uv := fmt.Sprintf("%d", w.selected.UidValidity) uid := fmt.Sprintf("%d", mi.Uid) w.worker.Debugf("caching header for message %s.%s", uv, uid) hdr := bytes.NewBuffer(nil) err := textproto.WriteHeader(hdr, mi.RFC822Headers.Header.Header) if err != nil { w.worker.Errorf("cannot write header %s.%s: %v", uv, uid, 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.%s: %v", uv, uid, err) return } err = w.cache.Put([]byte("header."+uv+"."+uid), data.Bytes(), nil) if err != nil { w.worker.Errorf("cannot write header for message %s.%s: %v", uv, uid, err) return } } func (w *IMAPWorker) getCachedHeaders(msg *types.FetchMessageHeaders) []uint32 { w.worker.Tracef("Retrieving headers from cache: %v", msg.Uids) var need []uint32 uv := fmt.Sprintf("%d", w.selected.UidValidity) for _, uid := range msg.Uids { u := fmt.Sprintf("%d", uid) data, err := w.cache.Get([]byte("header."+uv+"."+u), 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.%s: %v", uv, u, 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.%s: %v", uv, u, 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 cacheDir() (string, error) { dir, err := os.UserCacheDir() if err != nil { dir, err = homedir.Expand("~/.cache") if err != nil { return "", err } } return path.Join(dir, "aerc"), nil } // 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() }