aerc/worker/notmuch/message.go
Julian Pidancet c7bfe4e490 notmuch: add maildir support
By associating the notmuch database with a maildir store, we can add the
Copy/Move/Delete operations on messages to the notmuch backend.
This change assumes that the notmuch database location is also the root
of the maildir store.

In a previous change, we added the ability to dynamically add and remove
message files to the notmuch DB. This change uses this facility to
synchronize the database with the filesystem operations on maildir
files.

While it's still possible to use the query-map file to create virtual
folders from notmuch search queries, the sidebar is now loaded with the
folders found in the maildir store.

With notmuch, two identical but distinct message files can be indexed in
the database with the same key. This change takes extra care of only
deleting or removing message files from the maildir corresponding to the
folder that is currently selected (if any).

Implements: https://todo.sr.ht/~rjarry/aerc/88
Fixes: https://todo.sr.ht/~rjarry/aerc/73
Signed-off-by: Julian Pidancet <julian.pidancet@oracle.com>
Acked-by: Robin Jarry <robin@jarry.cc>
Acked-by: Tim Culverhouse <tim@timculverhouse.com>
2022-10-27 21:45:24 +02:00

287 lines
6.1 KiB
Go

//go:build notmuch
// +build notmuch
package notmuch
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/emersion/go-maildir"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/lib"
notmuch "git.sr.ht/~rjarry/aerc/worker/notmuch/lib"
)
type Message struct {
uid uint32
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) {
return lib.MessageInfo(m)
}
// 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 := lib.ReadMessage(f)
if err != nil {
return nil, fmt.Errorf("could not read message: %w", err)
}
return lib.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.Flag, enable bool) error {
// Translate the flag into a notmuch tag, ignoring no-op flags.
var tag string
switch flag {
case models.SeenFlag:
// Note: Inverted properly later
tag = "unread"
case models.AnsweredFlag:
tag = "replied"
case models.FlaggedFlag:
tag = "flagged"
default:
return fmt.Errorf("Notmuch doesn't support flag %v", flag)
}
// Get the current state of the flag.
// Note that notmuch handles models.SeenFlag 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 flag == models.SeenFlag {
oldState = !oldState
}
// Skip if flag already in correct state.
if oldState == enable {
return nil
}
if !enable {
if flag == models.SeenFlag {
return m.AddTag("unread")
} else {
return m.RemoveTag(tag)
}
} else {
if flag == models.SeenFlag {
return m.RemoveTag("unread")
} else {
return m.AddTag(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)
}
// 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.Flag, error) {
var flags []models.Flag
seen := true
tags, err := m.Tags()
if err != nil {
return nil, err
}
for _, tag := range tags {
switch tag {
case "replied":
flags = append(flags, models.AnsweredFlag)
case "flagged":
flags = append(flags, models.FlaggedFlag)
case "unread":
seen = false
default:
continue
}
}
if seen {
flags = append(flags, models.SeenFlag)
}
return flags, nil
}
func (m *Message) UID() uint32 {
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(dir maildir.Dir) error {
filenames, err := m.db.MsgFilenames(m.key)
if err != nil {
return err
}
for _, filename := range filenames {
if dirContains(dir, filename) {
err := m.db.DeleteMessage(filename)
if err != nil {
return err
}
if err := os.Remove(filename); err != nil {
return err
}
return nil
}
}
return fmt.Errorf("no matching message file found in %s", string(dir))
}
func (m *Message) Copy(target maildir.Dir) error {
filename, err := m.Filename()
if err != nil {
return err
}
source, key := parseFilename(filename)
if key == "" {
return fmt.Errorf("failed to parse message filename: %s", filename)
}
newKey, err := source.Copy(target, key)
if err != nil {
return err
}
newFilename, err := target.Filename(newKey)
if err != nil {
return err
}
_, err = m.db.IndexFile(newFilename)
return err
}
func (m *Message) Move(srcDir, destDir maildir.Dir) error {
var src string
filenames, err := m.db.MsgFilenames(m.key)
if err != nil {
return err
}
for _, filename := range filenames {
if dirContains(srcDir, filename) {
src = filename
break
}
}
if src == "" {
return fmt.Errorf("no matching message file found in %s", string(srcDir))
}
// Remove encoded UID information from the key to prevent sync issues
name := lib.StripUIDFromMessageFilename(filepath.Base(src))
dest := filepath.Join(string(destDir), "cur", name)
if err := m.db.DeleteMessage(src); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil {
return err
}
_, err = m.db.IndexFile(dest)
return err
}
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
}
func dirContains(dir maildir.Dir, filename string) bool {
for _, sub := range []string{"cur", "new"} {
match, _ := filepath.Match(filepath.Join(string(dir), sub, "*"), filename)
if match {
return true
}
}
return false
}