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
+64
View File
@@ -0,0 +1,64 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type Align struct {
Pos app.AlignPosition `opt:"pos" metavar:"top|center|bottom" action:"ParsePos" complete:"CompletePos" desc:"Position."`
}
func init() {
commands.Register(Align{})
}
func (Align) Description() string {
return "Align the message list view."
}
var posNames []string = []string{"top", "center", "bottom"}
func (a *Align) ParsePos(arg string) error {
switch arg {
case "top":
a.Pos = app.AlignTop
case "center":
a.Pos = app.AlignCenter
case "bottom":
a.Pos = app.AlignBottom
default:
return errors.New("invalid alignment")
}
return nil
}
func (a *Align) CompletePos(arg string) []string {
return commands.FilterList(posNames, arg, commands.QuoteSpace)
}
func (Align) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (Align) Aliases() []string {
return []string{"align"}
}
func (a Align) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("no account selected")
}
msgList := acct.Messages()
if msgList == nil {
return errors.New("no message list available")
}
msgList.AlignMessage(a.Pos)
ui.Invalidate()
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package account
import (
"errors"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
)
type ChangeFolder struct {
Account string `opt:"-a" complete:"CompleteAccount" desc:"Change to specified account."`
Folder string `opt:"..." complete:"CompleteFolderAndNotmuch" desc:"Folder name."`
}
func init() {
commands.Register(ChangeFolder{})
}
func (ChangeFolder) Description() string {
return "Change the folder shown in the message list."
}
func (ChangeFolder) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (ChangeFolder) Aliases() []string {
return []string{"cf"}
}
func (c *ChangeFolder) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (c *ChangeFolder) CompleteFolderAndNotmuch(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return nil
}
retval := commands.FilterList(
acct.Directories().List(), arg,
func(s string) string {
dir := acct.Directories().Directory(s)
if dir != nil && dir.Role != models.QueryRole {
s = opt.QuoteArg(s)
}
return s
},
)
if acct.AccountConfig().Backend == "notmuch" {
notmuchcomps := handleNotmuchComplete(arg)
for _, prefix := range notmuch_search_terms {
if strings.HasPrefix(arg, prefix) {
return notmuchcomps
}
}
retval = append(retval, notmuchcomps...)
}
return retval
}
func (c ChangeFolder) Execute([]string) error {
var target string
var acct *app.AccountView
var err error
args := opt.LexArgs(c.Folder)
if c.Account != "" {
acct, err = app.Account(c.Account)
if err != nil {
return err
}
} else {
acct = app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
}
if args.Count() == 0 {
return errors.New("<folder> is required. Usage: cf [-a <account>] <folder>")
}
if acct.AccountConfig().Backend == "notmuch" {
// With notmuch, :cf can change to a "dynamic folder" that
// contains the result of a query. Preserve the entered
// arguments verbatim.
target = args.String()
} else {
if args.Count() != 1 {
return errors.New("Unexpected argument(s). Usage: cf [-a <account>] <folder>")
}
target = args.Arg(0)
}
finalize := func(msg types.WorkerMessage) {
handleDirOpenResponse(acct, msg)
}
dirlist := acct.Directories()
if dirlist == nil {
return errors.New("No directory list found")
}
if target == "-" {
dir := dirlist.Previous()
if dir != "" {
target = dir
} else {
return errors.New("No previous folder to return to")
}
}
dirlist.Open(target, "", 0*time.Second, finalize, false)
return nil
}
func handleDirOpenResponse(acct *app.AccountView, msg types.WorkerMessage) {
// As we're waiting for the worker to report status we must run
// the rest of the actions in this callback.
switch msg := msg.(type) {
case *types.Error:
app.PushError(msg.Error.Error())
case *types.Done:
// reset store filtering if we switched folders
store := acct.Store()
if store != nil {
store.ApplyClear()
acct.SetStatus(state.SearchFilterClear())
}
// focus account tab
acct.Select()
}
}
+36
View File
@@ -0,0 +1,36 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type CheckMail struct{}
func init() {
commands.Register(CheckMail{})
}
func (CheckMail) Description() string {
return "Check for new mail on the selected account."
}
func (CheckMail) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (CheckMail) Aliases() []string {
return []string{"check-mail"}
}
func (CheckMail) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
acct.CheckMailReset()
acct.CheckMail()
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/state"
)
type Clear struct {
Selected bool `opt:"-s" desc:"Select first message after clearing."`
}
func init() {
commands.Register(Clear{})
}
func (Clear) Description() string {
return "Clear the current search or filter criteria."
}
func (Clear) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (Clear) Aliases() []string {
return []string{"clear"}
}
func (c Clear) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := acct.Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
if c.Selected {
defer store.Select("")
}
store.ApplyClear()
acct.SetStatus(state.SearchFilterClear())
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package account
import (
"errors"
"fmt"
"io"
gomail "net/mail"
"regexp"
"strings"
"github.com/emersion/go-message/mail"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
)
type Compose struct {
Headers string `opt:"-H" action:"ParseHeader" desc:"Add the specified header to the message."`
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
Body string `opt:"..." required:"false"`
}
func init() {
commands.Register(Compose{})
}
func (Compose) Description() string {
return "Open the compose window to write a new email."
}
func (Compose) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (c *Compose) ParseHeader(arg string) error {
if strings.Contains(arg, ":") {
// ensure first colon is followed by a single space
re := regexp.MustCompile(`^(.*?):\s*(.*)`)
c.Headers += re.ReplaceAllString(arg, "$1: $2\r\n")
} else {
c.Headers += arg + ":\r\n"
}
return nil
}
func (*Compose) CompleteTemplate(arg string) []string {
return commands.GetTemplates(arg)
}
func (Compose) Aliases() []string {
return []string{"compose"}
}
func (c Compose) Execute(args []string) error {
if c.Headers != "" {
if c.Body != "" {
c.Body = c.Headers + "\r\n" + c.Body
} else {
c.Body = c.Headers + "\r\n\r\n"
}
}
if c.Template == "" {
c.Template = config.Templates.NewMessage
}
editHeaders := (config.Compose.EditHeaders || c.Edit) && !c.NoEdit
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
msg, err := gomail.ReadMessage(strings.NewReader(c.Body))
if errors.Is(err, io.EOF) { // completely empty
msg = &gomail.Message{Body: strings.NewReader("")}
} else if err != nil {
return fmt.Errorf("mail.ReadMessage: %w", err)
}
headers := mail.HeaderFromMap(msg.Header)
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
c.Template, &headers, nil, msg.Body)
if err != nil {
return err
}
composer.Tab = app.NewTab(composer, "New email")
if c.SkipEditor {
composer.Terminal().Close()
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Connection struct{}
func init() {
commands.Register(Connection{})
}
func (Connection) Description() string {
return "Disconnect or reconnect the current account."
}
func (Connection) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (Connection) Aliases() []string {
return []string{"connect", "disconnect"}
}
func (c Connection) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
cb := func(msg types.WorkerMessage) {
acct.SetStatus(state.ConnectionActivity(""))
}
if args[0] == "connect" {
acct.Worker().PostAction(&types.Connect{}, cb)
acct.SetStatus(state.ConnectionActivity("Connecting..."))
} else {
acct.Worker().PostAction(&types.Disconnect{}, cb)
acct.SetStatus(state.ConnectionActivity("Disconnecting..."))
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type ExpandCollapseFolder struct {
Folder string `opt:"folder" required:"false" complete:"CompleteFolder" desc:"Folder name."`
}
func init() {
commands.Register(ExpandCollapseFolder{})
}
func (ExpandCollapseFolder) Description() string {
return "Expand or collapse the current folder."
}
func (ExpandCollapseFolder) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (ExpandCollapseFolder) Aliases() []string {
return []string{"expand-folder", "collapse-folder"}
}
func (*ExpandCollapseFolder) CompleteFolder(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, nil)
}
func (e ExpandCollapseFolder) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
if e.Folder == "" {
e.Folder = acct.Directories().Selected()
}
if args[0] == "expand-folder" {
acct.Directories().ExpandFolder(e.Folder)
} else {
acct.Directories().CollapseFolder(e.Folder)
}
return nil
}
+198
View File
@@ -0,0 +1,198 @@
package account
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"git.sr.ht/~rjarry/aerc/models"
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type ExportMbox struct {
Filename string `opt:"filename" complete:"CompleteFilename" desc:"Output file path."`
}
func init() {
commands.Register(ExportMbox{})
}
func (ExportMbox) Description() string {
return "Export messages in the current folder to an mbox file."
}
func (ExportMbox) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (ExportMbox) Aliases() []string {
return []string{"export-mbox"}
}
func (*ExportMbox) CompleteFilename(arg string) []string {
return commands.CompletePath(arg, false)
}
func (e ExportMbox) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := acct.Store()
if store == nil {
return errors.New("No message store selected")
}
e.Filename = xdg.ExpandHome(e.Filename)
fi, err := os.Stat(e.Filename)
if err == nil && fi.IsDir() {
if path := acct.SelectedDirectory(); path != "" {
if f := filepath.Base(path); f != "" {
e.Filename = filepath.Join(e.Filename, f+".mbox")
}
}
}
app.PushStatus("Exporting to "+e.Filename, 10*time.Second)
// uids of messages to export
var uids []models.UID
// check if something is marked - we export that then
msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages)
if !ok {
msgProvider = app.SelectedAccount()
}
if msgProvider != nil {
marked, err := msgProvider.MarkedMessages()
if err == nil && len(marked) > 0 {
uids, err = sortMarkedUids(marked, store)
if err != nil {
return err
}
}
}
// if no messages were marked, we export everything
if len(uids) == 0 {
var err error
uids, err = sortAllUids(store)
if err != nil {
return err
}
}
go func() {
defer log.PanicHandler()
file, err := os.Create(e.Filename)
if err != nil {
log.Errorf("failed to create file: %v", err)
app.PushError(err.Error())
return
}
defer file.Close()
var mu sync.Mutex
var ctr uint
var retries int
done := make(chan bool)
t := time.Now()
total := len(uids)
for len(uids) > 0 {
if retries > 0 {
if retries > 10 {
errorMsg := fmt.Sprintf("too many retries: %d; stopping export", retries)
log.Errorf(errorMsg)
app.PushError(args[0] + " " + errorMsg)
break
}
sleeping := time.Duration(retries * 1e9 * 2)
log.Debugf("sleeping for %s before retrying; retries: %d", sleeping, retries)
time.Sleep(sleeping)
}
log.Debugf("fetching %d for export", len(uids))
acct.Worker().PostAction(&types.FetchFullMessages{
Uids: uids,
}, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
done <- true
case *types.Error:
log.Errorf("failed to fetch message: %v", msg.Error)
app.PushError(args[0] + " error encountered: " + msg.Error.Error())
done <- false
case *types.FullMessage:
mu.Lock()
err := mboxer.Write(file, msg.Content.Reader, "", t)
if err != nil {
log.Warnf("failed to write mbox: %v", err)
}
for i, uid := range uids {
if uid == msg.Content.Uid {
uids = append(uids[:i], uids[i+1:]...)
break
}
}
ctr++
mu.Unlock()
}
})
if ok := <-done; ok {
break
}
retries++
}
statusInfo := fmt.Sprintf("Exported %d of %d messages to %s.", ctr, total, e.Filename)
app.PushStatus(statusInfo, 10*time.Second)
log.Debugf(statusInfo)
}()
return nil
}
func sortMarkedUids(marked []models.UID, store *lib.MessageStore) ([]models.UID, error) {
lookup := map[models.UID]bool{}
for _, uid := range marked {
lookup[uid] = true
}
uids := []models.UID{}
iter := store.UidsIterator()
for iter.Next() {
uid, ok := iter.Value().(models.UID)
if !ok {
return nil, errors.New("Invalid message UID value")
}
_, marked := lookup[uid]
if marked {
uids = append(uids, uid)
}
}
return uids, nil
}
func sortAllUids(store *lib.MessageStore) ([]models.UID, error) {
uids := []models.UID{}
iter := store.UidsIterator()
for iter.Next() {
uid, ok := iter.Value().(models.UID)
if !ok {
return nil, errors.New("Invalid message UID value")
}
uids = append(uids, uid)
}
return uids, nil
}
+189
View File
@@ -0,0 +1,189 @@
package account
import (
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"sync/atomic"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"git.sr.ht/~rjarry/aerc/models"
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type ImportMbox struct {
Path string `opt:"path" complete:"CompleteFilename" desc:"Input file path or URL."`
}
func init() {
commands.Register(ImportMbox{})
}
func (ImportMbox) Description() string {
return "Import all messages from an (gzipped) mbox file to the current folder."
}
func (ImportMbox) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (ImportMbox) Aliases() []string {
return []string{"import-mbox"}
}
func (*ImportMbox) CompleteFilename(arg string) []string {
return commands.CompletePath(arg, false)
}
func (i ImportMbox) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := acct.Store()
if store == nil {
return errors.New("No message store selected")
}
folder := acct.SelectedDirectory()
if folder == "" {
return errors.New("No directory selected")
}
importFolder := func(r io.ReadCloser) {
defer log.PanicHandler()
defer r.Close()
messages, err := mboxer.Read(r)
if err != nil {
app.PushError(err.Error())
return
}
var appended uint32
for i, m := range messages {
done := make(chan bool)
var retries int = 4
for retries > 0 {
var buf bytes.Buffer
r, err := m.NewReader()
if err != nil {
log.Errorf("could not get reader for uid %d", m.UID())
break
}
nbytes, _ := io.Copy(&buf, r)
store.Append(
folder,
models.SeenFlag,
time.Now(),
&buf,
int(nbytes),
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Unsupported:
errMsg := fmt.Sprintf("%s: AppendMessage is unsupported", args[0])
log.Errorf(errMsg)
app.PushError(errMsg)
return
case *types.Error:
log.Errorf("AppendMessage failed: %v", msg.Error)
done <- false
case *types.Done:
atomic.AddUint32(&appended, 1)
done <- true
}
},
)
select {
case ok := <-done:
if ok {
retries = 0
} else {
// error encountered; try to append again after a quick nap
retries -= 1
sleeping := time.Duration((5 - retries) * 1e9)
log.Debugf("sleeping for %s before append message %d again", sleeping, i)
time.Sleep(sleeping)
}
case <-time.After(30 * time.Second):
log.Warnf("timed-out; appended %d of %d", appended, len(messages))
return
}
}
}
infoStr := fmt.Sprintf("%s: imported %d of %d successfully.", args[0], appended, len(messages))
log.Debugf(infoStr)
app.PushSuccess(infoStr)
}
var buf []byte
path := i.Path
if ok, err := regexp.MatchString("^(http[s]\\:|www\\.)", path); ok && err == nil {
resp, err := http.Get(path)
if err != nil {
return err
}
buf, err = io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return err
}
} else {
path = xdg.ExpandHome(path)
buf, err = os.ReadFile(path)
if err != nil {
return err
}
}
var r io.ReadCloser
// detect gzip format compressed files as specified in RFC 1952
if len(buf) >= 2 && buf[0] == 0x1f && buf[1] == 0x8b {
var err error
r, err = gzip.NewReader(bytes.NewReader(buf))
if err != nil {
return err
}
} else {
r = io.NopCloser(bytes.NewReader(buf))
}
statusInfo := fmt.Sprintln("Importing", path, "to folder", folder)
app.PushStatus(statusInfo, 10*time.Second)
log.Debugf(statusInfo)
if len(store.Uids()) > 0 {
confirm := app.NewSelectorDialog(
"Selected directory is not empty",
fmt.Sprintf("Import mbox file to %s anyways?", folder),
[]string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(),
func(option string, err error) {
app.CloseDialog()
if option == "Yes" {
go importFolder(r)
} else {
_ = r.Close()
}
},
)
app.AddDialog(confirm)
} else {
go importFolder(r)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package account
import (
"errors"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
)
type MakeDir struct {
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Folder name."`
}
func init() {
commands.Register(MakeDir{})
}
func (MakeDir) Description() string {
return "Create and change to a new folder."
}
func (MakeDir) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (MakeDir) Aliases() []string {
return []string{"mkdir"}
}
func (*MakeDir) CompleteFolder(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return nil
}
sep := app.SelectedAccount().Worker().PathSeparator()
return commands.FilterList(
acct.Directories().List(), arg,
func(s string) string {
return opt.QuoteArg(s) + sep
},
)
}
func (m MakeDir) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
acct.Worker().PostAction(&types.CreateDirectory{
Directory: m.Folder,
}, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("Directory created.", 10*time.Second)
acct.Directories().Open(m.Folder, "", 0, nil, false)
case *types.Error:
app.PushError(msg.Error.Error())
}
})
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type NextPrevFolder struct {
Offset int `opt:"n" default:"1"`
}
func init() {
commands.Register(NextPrevFolder{})
}
func (NextPrevFolder) Description() string {
return "Cycle to the next or previous folder shown in the sidebar."
}
func (NextPrevFolder) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (NextPrevFolder) Aliases() []string {
return []string{"next-folder", "prev-folder"}
}
func (np NextPrevFolder) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
if args[0] == "prev-folder" {
acct.Directories().NextPrev(-np.Offset)
} else {
acct.Directories().NextPrev(np.Offset)
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type NextPrevResult struct{}
func init() {
commands.Register(NextPrevResult{})
}
func (NextPrevResult) Description() string {
return "Select the next or previous search result."
}
func (NextPrevResult) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (NextPrevResult) Aliases() []string {
return []string{"next-result", "prev-result"}
}
func (NextPrevResult) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
if args[0] == "prev-result" {
store := acct.Store()
if store != nil {
store.PrevResult()
}
ui.Invalidate()
} else {
store := acct.Store()
if store != nil {
store.NextResult()
}
ui.Invalidate()
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package account
import (
"errors"
"fmt"
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type NextPrevMsg struct {
Amount int `opt:"n" default:"1" metavar:"<n>[%]" action:"ParseAmount"`
Percent bool
}
func init() {
commands.Register(NextPrevMsg{})
}
func (NextPrevMsg) Description() string {
return "Select the next or previous message in the message list."
}
func (NextPrevMsg) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (np *NextPrevMsg) ParseAmount(arg string) error {
if strings.HasSuffix(arg, "%") {
np.Percent = true
arg = strings.TrimSuffix(arg, "%")
}
i, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
return err
}
np.Amount = int(i)
return nil
}
func (NextPrevMsg) Aliases() []string {
return []string{"next", "next-message", "prev", "prev-message"}
}
func (np NextPrevMsg) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := acct.Store()
if store == nil {
return fmt.Errorf("No message store set.")
}
n := np.Amount
if np.Percent {
n = int(float64(acct.Messages().Height()) * (float64(n) / 100.0))
}
if args[0] == "prev-message" || args[0] == "prev" {
store.NextPrev(-n)
} else {
store.NextPrev(n)
}
if mv, ok := app.SelectedTabContent().(*app.MessageViewer); ok {
reloadViewer := func(nextMsg *models.MessageInfo) {
if nextMsg.Error != nil {
app.PushError(nextMsg.Error.Error())
return
}
lib.NewMessageStoreView(nextMsg, mv.MessageView().SeenFlagSet(),
store, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
nextMv, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, nextMv,
nextMsg.Envelope.Subject, true)
})
}
if nextMsg := store.Selected(); nextMsg != nil {
reloadViewer(nextMsg)
} else {
store.FetchHeaders([]models.UID{store.SelectedUid()},
func(msg types.WorkerMessage) {
if m, ok := msg.(*types.MessageInfo); ok {
reloadViewer(m.Info)
}
})
}
}
ui.Invalidate()
return nil
}
+127
View File
@@ -0,0 +1,127 @@
package account
import (
"errors"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Query struct {
Account string `opt:"-a" complete:"CompleteAccount" desc:"Account name."`
Name string `opt:"-n" desc:"Force name of virtual folder."`
Force bool `opt:"-f" desc:"Replace existing query if any."`
Query string `opt:"..." complete:"CompleteNotmuch" desc:"Notmuch query."`
}
func init() {
commands.Register(Query{})
}
func (Query) Description() string {
return "Create a virtual folder using the specified notmuch query."
}
func (Query) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (Query) Aliases() []string {
return []string{"query"}
}
func (Query) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (q Query) Execute([]string) error {
var acct *app.AccountView
if q.Account == "" {
acct = app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
} else {
var err error
acct, err = app.Account(q.Account)
if err != nil {
return err
}
}
if acct.AccountConfig().Backend != "notmuch" {
return errors.New(":query is only available for notmuch accounts")
}
finalize := func(msg types.WorkerMessage) {
handleDirOpenResponse(acct, msg)
}
name := q.Name
if name == "" {
name = q.Query
}
acct.Directories().Open(name, q.Query, 0*time.Second, finalize, q.Force)
return nil
}
func (*Query) CompleteNotmuch(arg string) []string {
return handleNotmuchComplete(arg)
}
var notmuch_search_terms = []string{
"from:",
"to:",
"tag:",
"date:",
"attachment:",
"mimetype:",
"subject:",
"body:",
"id:",
"thread:",
"folder:",
"path:",
}
func handleNotmuchComplete(arg string) []string {
prefixes := []string{"from:", "to:"}
for _, prefix := range prefixes {
if strings.HasPrefix(arg, prefix) {
arg = strings.TrimPrefix(arg, prefix)
return commands.FilterList(
commands.GetAddress(arg), arg,
func(v string) string { return prefix + v },
)
}
}
prefixes = []string{"tag:"}
for _, prefix := range prefixes {
if strings.HasPrefix(arg, prefix) {
arg = strings.TrimPrefix(arg, prefix)
return commands.FilterList(
commands.GetLabels(arg), arg,
func(v string) string { return prefix + v },
)
}
}
prefixes = []string{"path:", "folder:"}
dbPath := strings.TrimPrefix(app.SelectedAccount().AccountConfig().Source, "notmuch://")
for _, prefix := range prefixes {
if strings.HasPrefix(arg, prefix) {
arg = strings.TrimPrefix(arg, prefix)
return commands.FilterList(
commands.CompletePath(dbPath+arg, true), arg,
func(v string) string { return prefix + strings.TrimPrefix(v, dbPath) },
)
}
}
return commands.FilterList(notmuch_search_terms, arg, nil)
}
+88
View File
@@ -0,0 +1,88 @@
package account
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
)
type Recover struct {
Force bool `opt:"-f" desc:"Delete recovered file after opening the composer."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
File string `opt:"file" complete:"CompleteFile" desc:"Recover file path."`
}
func init() {
commands.Register(Recover{})
}
func (Recover) Description() string {
return "Resume composing a message that was not sent nor postponed."
}
func (Recover) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (Recover) Aliases() []string {
return []string{"recover"}
}
func (Recover) Options() string {
return "feE"
}
func (*Recover) CompleteFile(arg string) []string {
// file name of temp file is hard-coded in the NewComposer() function
files, err := filepath.Glob(
filepath.Join(os.TempDir(), "aerc-compose-*.eml"),
)
if err != nil {
return nil
}
return commands.FilterList(files, arg, nil)
}
func (r Recover) Execute(args []string) error {
file, err := os.Open(r.File)
if err != nil {
return err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return err
}
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
"", nil, nil, bytes.NewReader(data))
if err != nil {
return err
}
composer.Tab = app.NewTab(composer, "Recovered")
// remove file if force flag is set
if r.Force {
err = os.Remove(r.File)
if err != nil {
return err
}
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package account
import (
"errors"
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
)
type RemoveDir struct {
Force bool `opt:"-f" desc:"Remove the directory even if it contains messages."`
Folder string `opt:"folder" complete:"CompleteFolder" required:"false" desc:"Folder name."`
}
func init() {
commands.Register(RemoveDir{})
}
func (RemoveDir) Description() string {
return "Remove folder."
}
func (RemoveDir) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (RemoveDir) Aliases() []string {
return []string{"rmdir"}
}
func (RemoveDir) CompleteFolder(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, opt.QuoteArg)
}
func (r RemoveDir) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
current := acct.Directories().SelectedDirectory()
toRemove := current
if r.Folder != "" {
toRemove = acct.Directories().Directory(r.Folder)
if toRemove == nil {
return fmt.Errorf("No such directory: %s", r.Folder)
}
}
role := toRemove.Role
// Check for any messages in the directory.
if role != models.QueryRole && toRemove.Exists > 0 && !r.Force {
return errors.New("Refusing to remove non-empty directory; use -f")
}
if role == models.VirtualRole {
return errors.New("Cannot remove a virtual node")
}
if toRemove != current {
r.remove(acct, toRemove, func() {})
return nil
}
curDir := current.Name
var newDir string
dirFound := false
oldDir := acct.Directories().Previous()
if oldDir != "" {
present := false
for _, dir := range acct.Directories().List() {
if dir == oldDir {
present = true
break
}
}
if oldDir != curDir && present {
newDir = oldDir
dirFound = true
}
}
defaultDir := acct.AccountConfig().Default
if !dirFound && defaultDir != curDir {
for _, dir := range acct.Directories().List() {
if defaultDir == dir {
newDir = dir
dirFound = true
break
}
}
}
if !dirFound {
for _, dir := range acct.Directories().List() {
if dir != curDir {
newDir = dir
dirFound = true
break
}
}
}
if !dirFound {
return errors.New("No directory to move to afterwards!")
}
reopenCurrentDir := func() { acct.Directories().Open(curDir, "", 0, nil, false) }
acct.Directories().Open(newDir, "", 0, func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Done:
break
case *types.Error:
app.PushError("Could not change directory")
reopenCurrentDir()
return
default:
return
}
r.remove(acct, toRemove, reopenCurrentDir)
}, false)
return nil
}
func (r RemoveDir) remove(acct *app.AccountView, dir *models.Directory, onErr func()) {
acct.Worker().PostAction(&types.RemoveDirectory{
Directory: dir.Name,
Quiet: r.Force,
}, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("Directory removed.", 10*time.Second)
case *types.Error:
app.PushError(msg.Error.Error())
onErr()
case *types.Unsupported:
app.PushError(":rmdir is not supported by the backend.")
onErr()
}
})
}
+223
View File
@@ -0,0 +1,223 @@
package account
import (
"errors"
"fmt"
"net/textproto"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type SearchFilter struct {
Read bool `opt:"-r" action:"ParseRead" desc:"Search for read messages."`
Unread bool `opt:"-u" action:"ParseUnread" desc:"Search for unread messages."`
Body bool `opt:"-b" desc:"Search in the body of the messages."`
All bool `opt:"-a" desc:"Search in the entire text of the messages."`
UseExtension bool `opt:"-e" desc:"Use custom search backend extension."`
Headers textproto.MIMEHeader `opt:"-H" action:"ParseHeader" metavar:"<header>:<value>" desc:"Search for messages with the specified header."`
WithFlags models.Flags `opt:"-x" action:"ParseFlag" complete:"CompleteFlag" desc:"Search messages with specified flag."`
WithoutFlags models.Flags `opt:"-X" action:"ParseNotFlag" complete:"CompleteFlag" desc:"Search messages without specified flag."`
To []string `opt:"-t" action:"ParseTo" complete:"CompleteAddress" desc:"Search for messages To:<address>."`
From []string `opt:"-f" action:"ParseFrom" complete:"CompleteAddress" desc:"Search for messages From:<address>."`
Cc []string `opt:"-c" action:"ParseCc" complete:"CompleteAddress" desc:"Search for messages Cc:<address>."`
StartDate time.Time `opt:"-d" action:"ParseDate" complete:"CompleteDate" desc:"Search for messages within a particular date range."`
EndDate time.Time
Terms string `opt:"..." required:"false" complete:"CompleteTerms" desc:"Search term."`
}
func init() {
commands.Register(SearchFilter{})
}
func (SearchFilter) Description() string {
return "Search or filter the current folder."
}
func (SearchFilter) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (SearchFilter) Aliases() []string {
return []string{"search", "filter"}
}
func (*SearchFilter) CompleteFlag(arg string) []string {
return commands.FilterList(commands.GetFlagList(), arg, commands.QuoteSpace)
}
func (*SearchFilter) CompleteAddress(arg string) []string {
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
}
func (*SearchFilter) CompleteDate(arg string) []string {
return commands.FilterList(commands.GetDateList(), arg, commands.QuoteSpace)
}
func (s *SearchFilter) CompleteTerms(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return nil
}
if acct.AccountConfig().Backend == "notmuch" {
return handleNotmuchComplete(arg)
}
caps := acct.Worker().Backend.Capabilities()
if caps != nil && caps.Has("X-GM-EXT-1") && s.UseExtension {
return handleXGMEXTComplete(arg)
}
return nil
}
func (s *SearchFilter) ParseRead(arg string) error {
s.WithFlags |= models.SeenFlag
s.WithoutFlags &^= models.SeenFlag
return nil
}
func (s *SearchFilter) ParseUnread(arg string) error {
s.WithFlags &^= models.SeenFlag
s.WithoutFlags |= models.SeenFlag
return nil
}
var flagValues = map[string]models.Flags{
"seen": models.SeenFlag,
"answered": models.AnsweredFlag,
"forwarded": models.ForwardedFlag,
"flagged": models.FlaggedFlag,
"draft": models.DraftFlag,
}
func (s *SearchFilter) ParseFlag(arg string) error {
f, ok := flagValues[strings.ToLower(arg)]
if !ok {
return fmt.Errorf("%q unknown flag", arg)
}
s.WithFlags |= f
s.WithoutFlags &^= f
return nil
}
func (s *SearchFilter) ParseNotFlag(arg string) error {
f, ok := flagValues[strings.ToLower(arg)]
if !ok {
return fmt.Errorf("%q unknown flag", arg)
}
s.WithFlags &^= f
s.WithoutFlags |= f
return nil
}
func (s *SearchFilter) ParseHeader(arg string) error {
name, value, hasColon := strings.Cut(arg, ":")
if !hasColon {
return fmt.Errorf("%q invalid syntax", arg)
}
if s.Headers == nil {
s.Headers = make(textproto.MIMEHeader)
}
s.Headers.Add(name, strings.TrimSpace(value))
return nil
}
func (s *SearchFilter) ParseTo(arg string) error {
s.To = append(s.To, arg)
return nil
}
func (s *SearchFilter) ParseFrom(arg string) error {
s.From = append(s.From, arg)
return nil
}
func (s *SearchFilter) ParseCc(arg string) error {
s.Cc = append(s.Cc, arg)
return nil
}
func (s *SearchFilter) ParseDate(arg string) error {
start, end, err := parse.DateRange(arg)
if err != nil {
return err
}
s.StartDate = start
s.EndDate = end
return nil
}
func (s SearchFilter) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := acct.Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
criteria := types.SearchCriteria{
WithFlags: s.WithFlags,
WithoutFlags: s.WithoutFlags,
From: s.From,
To: s.To,
Cc: s.Cc,
Headers: s.Headers,
StartDate: s.StartDate,
EndDate: s.EndDate,
SearchBody: s.Body,
SearchAll: s.All,
Terms: []string{s.Terms},
UseExtension: s.UseExtension,
}
if args[0] == "filter" {
if len(args[1:]) == 0 {
return Clear{}.Execute([]string{"clear"})
}
acct.SetStatus(state.FilterActivity("Filtering..."), state.Search(""))
store.SetFilter(&criteria)
cb := func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Done); ok {
acct.SetStatus(state.FilterResult(strings.Join(args, " ")))
log.Tracef("Filter results: %v", store.Uids())
}
}
store.Sort(store.GetCurrentSortCriteria(), cb)
} else {
acct.SetStatus(state.Search("Searching..."))
cb := func(uids []models.UID) {
acct.SetStatus(state.Search(strings.Join(args, " ")))
log.Tracef("Search results: %v", uids)
store.ApplySearch(uids)
// TODO: Remove when stores have multiple OnUpdate handlers
ui.Invalidate()
}
store.Search(&criteria, cb)
}
return nil
}
func handleXGMEXTComplete(arg string) []string {
prefixes := []string{"from:", "to:", "deliveredto:", "cc:", "bcc:"}
for _, prefix := range prefixes {
if strings.HasPrefix(arg, prefix) {
arg = strings.TrimPrefix(arg, prefix)
return commands.FilterList(
commands.GetAddress(arg), arg,
func(v string) string { return prefix + v },
)
}
}
return commands.FilterList(xgmext.Terms, arg, nil)
}
+40
View File
@@ -0,0 +1,40 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type SelectMessage struct {
Index int `opt:"n"`
}
func init() {
commands.Register(SelectMessage{})
}
func (SelectMessage) Description() string {
return "Select the <N>th message in the message list."
}
func (SelectMessage) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (SelectMessage) Aliases() []string {
return []string{"select", "select-message"}
}
func (s SelectMessage) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
if acct.Messages().Empty() {
return nil
}
acct.Messages().Select(s.Index)
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package account
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/sort"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Sort struct {
Unused struct{} `opt:"-"`
// these fields are only used for completion
Reverse bool `opt:"-r" desc:"Sort in the reverse order."`
Criteria []string `opt:"criteria" complete:"CompleteCriteria" desc:"Sort criterion."`
}
func init() {
commands.Register(Sort{})
}
func (Sort) Description() string {
return "Sort the message list by the given criteria."
}
func (Sort) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (Sort) Aliases() []string {
return []string{"sort"}
}
var supportedCriteria = []string{
"arrival",
"cc",
"date",
"from",
"read",
"size",
"subject",
"to",
"flagged",
}
func (*Sort) CompleteCriteria(arg string) []string {
return commands.FilterList(supportedCriteria, arg, commands.QuoteSpace)
}
func (Sort) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected.")
}
store := acct.Store()
if store == nil {
return errors.New("Messages still loading.")
}
if c := store.Capabilities(); c != nil {
if !c.Sort {
return errors.New("Sorting is not available for this backend.")
}
}
var err error
var sortCriteria []*types.SortCriterion
if len(args[1:]) == 0 {
sortCriteria = acct.GetSortCriteria()
} else {
sortCriteria, err = sort.GetSortCriteria(args[1:])
if err != nil {
return err
}
}
acct.SetStatus(state.Sorting(true))
store.Sort(sortCriteria, func(msg types.WorkerMessage) {
if _, ok := msg.(*types.Done); ok {
acct.SetStatus(state.Sorting(false))
}
})
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package account
import (
"errors"
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type Split struct {
Size int `opt:"n" required:"false" action:"ParseSize"`
Delta bool
}
func init() {
commands.Register(Split{})
}
func (Split) Description() string {
return "Split the message list with a preview pane."
}
func (Split) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (s *Split) ParseSize(arg string) error {
i, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
return err
}
s.Size = int(i)
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
s.Delta = true
}
return nil
}
func (Split) Aliases() []string {
return []string{"split", "vsplit", "hsplit"}
}
func (s Split) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := app.SelectedAccount().Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
if s.Size == 0 && acct.SplitSize() == 0 {
if args[0] == "split" || args[0] == "hsplit" {
s.Size = app.SelectedAccount().Messages().Height() / 4
} else {
s.Size = app.SelectedAccount().Messages().Width() / 2
}
}
if s.Delta {
acct.SetSplitSize(acct.SplitSize() + s.Size)
return nil
}
if s.Size == acct.SplitSize() {
// Repeated commands of the same size have the effect of
// toggling the split
s.Size = 0
}
if s.Size < 0 {
// Don't allow split to go negative
s.Size = 1
}
switch args[0] {
case "split", "hsplit":
acct.Split(s.Size)
case "vsplit":
acct.Vsplit(s.Size)
}
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package account
import (
"bytes"
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/templates"
"git.sr.ht/~rjarry/aerc/models"
)
type ViewMessage struct {
Peek bool `opt:"-p" desc:"Peek message without marking it as read."`
Background bool `opt:"-b" desc:"Open message in a background tab."`
}
func init() {
commands.Register(ViewMessage{})
}
func (ViewMessage) Description() string {
return "View the selected message in a new tab."
}
func (ViewMessage) Context() commands.CommandContext {
return commands.MESSAGE_LIST
}
func (ViewMessage) Aliases() []string {
return []string{"view-message", "view"}
}
func (v ViewMessage) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
if acct.Messages().Empty() {
return nil
}
store := acct.Messages().Store()
msg := acct.Messages().Selected()
if msg == nil {
return nil
}
_, deleted := store.Deleted[msg.Uid]
if deleted {
return nil
}
if msg.Error != nil {
app.PushError(msg.Error.Error())
return nil
}
lib.NewMessageStoreView(
msg,
!v.Peek && acct.UiConfig().AutoMarkRead,
store,
app.CryptoProvider(),
app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
viewer, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
data := state.NewDataSetter()
data.SetAccount(acct.AccountConfig())
data.SetFolder(acct.Directories().SelectedDirectory())
data.SetHeaders(msg.RFC822Headers, &models.OriginalMail{})
var buf bytes.Buffer
err = templates.Render(acct.UiConfig().TabTitleViewer, &buf,
data.Data())
if err != nil {
acct.PushError(err)
return
}
if v.Background {
app.NewBackgroundTab(viewer, buf.String())
} else {
app.NewTab(viewer, buf.String())
}
})
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package commands
import (
"errors"
"os"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
var previousDir string
type ChangeDirectory struct {
Target string `opt:"directory" default:"~" complete:"CompleteTarget" desc:"Target directory."`
}
func init() {
Register(ChangeDirectory{})
}
func (ChangeDirectory) Description() string {
return "Change aerc's current working directory."
}
func (ChangeDirectory) Context() CommandContext {
return GLOBAL
}
func (ChangeDirectory) Aliases() []string {
return []string{"cd"}
}
func (*ChangeDirectory) CompleteTarget(arg string) []string {
return CompletePath(arg, true)
}
func (cd ChangeDirectory) Execute(args []string) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
if cd.Target == "-" {
if previousDir == "" {
return errors.New("No previous folder to return to")
} else {
cd.Target = previousDir
}
}
target := xdg.ExpandHome(cd.Target)
if err := os.Chdir(target); err == nil {
previousDir = cwd
app.UpdateStatus()
}
return err
}
+53
View File
@@ -0,0 +1,53 @@
package commands
import (
"fmt"
"git.sr.ht/~rjarry/aerc/app"
)
type Choose struct {
Unused struct{} `opt:"-"`
}
func init() {
Register(Choose{})
}
func (Choose) Description() string {
return "Prompt to choose from various options."
}
func (Choose) Context() CommandContext {
return GLOBAL
}
func (Choose) Aliases() []string {
return []string{"choose"}
}
func (Choose) Execute(args []string) error {
if len(args) < 5 || len(args)%4 != 1 {
return chooseUsage(args[0])
}
choices := []app.Choice{}
for i := 0; i+4 < len(args); i += 4 {
if args[i+1] != "-o" {
return chooseUsage(args[0])
}
choices = append(choices, app.Choice{
Key: args[i+2],
Text: args[i+3],
Command: args[i+4],
})
}
app.RegisterChoices(choices)
return nil
}
func chooseUsage(cmd string) error {
return fmt.Errorf("Usage: %s -o <key> <text> <command> [-o <key> <text> <command>]...", cmd)
}
+28
View File
@@ -0,0 +1,28 @@
package commands
import (
"git.sr.ht/~rjarry/aerc/app"
)
type Close struct{}
func init() {
Register(Close{})
}
func (Close) Description() string {
return "Close the focused tab."
}
func (Close) Context() CommandContext {
return MESSAGE_VIEWER | TERMINAL
}
func (Close) Aliases() []string {
return []string{"close"}
}
func (Close) Execute([]string) error {
app.RemoveTab(app.SelectedTabContent(), true)
return nil
}
+364
View File
@@ -0,0 +1,364 @@
package commands
import (
"bytes"
"errors"
"path"
"reflect"
"sort"
"strings"
"unicode"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/templates"
"git.sr.ht/~rjarry/aerc/models"
)
type CommandContext uint32
const (
NONE = 1 << iota
// available everywhere
GLOBAL
// only when a message list is focused
MESSAGE_LIST
// only when a message viewer is focused
MESSAGE_VIEWER
// only when a message composer editor is focused
COMPOSE_EDIT
// only when a message composer review screen is focused
COMPOSE_REVIEW
// only when a terminal
TERMINAL
)
func CurrentContext() CommandContext {
var context CommandContext = GLOBAL
switch tab := app.SelectedTabContent().(type) {
case *app.AccountView:
context |= MESSAGE_LIST
case *app.Composer:
if tab.Bindings() == "compose::review" {
context |= COMPOSE_REVIEW
} else {
context |= COMPOSE_EDIT
}
case *app.MessageViewer:
context |= MESSAGE_VIEWER
case *app.Terminal:
context |= TERMINAL
}
return context
}
type Command interface {
Description() string
Context() CommandContext
Aliases() []string
Execute([]string) error
}
var allCommands map[string]Command
func Register(cmd Command) {
if allCommands == nil {
allCommands = make(map[string]Command)
}
for _, alias := range cmd.Aliases() {
if allCommands[alias] != nil {
panic("duplicate command alias: " + alias)
}
allCommands[alias] = cmd
}
}
func ActiveCommands() []Command {
var cmds []Command
context := CurrentContext()
seen := make(map[reflect.Type]bool)
for _, cmd := range allCommands {
t := reflect.TypeOf(cmd)
if seen[t] {
continue
}
seen[t] = true
if cmd.Context()&context != 0 {
cmds = append(cmds, cmd)
}
}
return cmds
}
func ActiveCommandNames() []string {
var names []string
context := CurrentContext()
for alias, cmd := range allCommands {
if cmd.Context()&context != 0 {
names = append(names, alias)
}
}
return names
}
type NoSuchCommand string
func (err NoSuchCommand) Error() string {
return "Unknown command " + string(err)
}
// Expand non-ambiguous command abbreviations.
//
// q --> quit
// ar --> archive
// im --> import-mbox
func ExpandAbbreviations(name string) (string, Command, error) {
context := CurrentContext()
name = strings.TrimLeft(name, ": \t")
cmd, found := allCommands[name]
if found && cmd.Context()&context != 0 {
return name, cmd, nil
}
var candidate Command
var candidateName string
for alias, cmd := range allCommands {
if cmd.Context()&context == 0 || !strings.HasPrefix(alias, name) {
continue
}
if candidate != nil {
// We have more than one command partially
// matching the input.
return name, nil, NoSuchCommand(name)
}
// We have a partial match.
candidate = cmd
candidateName = alias
}
if candidate == nil {
return name, nil, NoSuchCommand(name)
}
return candidateName, candidate, nil
}
func ResolveCommand(
cmdline string, acct *config.AccountConfig, msg *models.MessageInfo,
) (string, Command, error) {
cmdline, err := ExpandTemplates(cmdline, acct, msg)
if err != nil {
return "", nil, err
}
name, rest, didCut := strings.Cut(cmdline, " ")
name, cmd, err := ExpandAbbreviations(name)
if err != nil {
return "", nil, err
}
cmdline = name
if didCut {
cmdline += " " + rest
}
return cmdline, cmd, nil
}
func templateData(
cfg *config.AccountConfig,
msg *models.MessageInfo,
) models.TemplateData {
var folder *models.Directory
acct := app.SelectedAccount()
if acct != nil {
folder = acct.Directories().SelectedDirectory()
}
if cfg == nil && acct != nil {
cfg = acct.AccountConfig()
}
if msg == nil && acct != nil {
msg, _ = acct.SelectedMessage()
}
data := state.NewDataSetter()
data.SetAccount(cfg)
data.SetFolder(folder)
data.SetInfo(msg, 0, false)
if acct != nil {
acct.SetStatus(func(s *state.AccountState, _ string) {
data.SetState(s)
})
}
return data.Data()
}
func ExecuteCommand(cmd Command, cmdline string) error {
args := opt.LexArgs(cmdline)
if args.Count() == 0 {
return errors.New("No arguments")
}
log.Tracef("executing command %s", args.String())
// copy zeroed struct
tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
if err := opt.ArgsToStruct(args.Clone(), tmp); err != nil {
return err
}
return tmp.Execute(args.Args())
}
// expand template expressions
func ExpandTemplates(
s string, cfg *config.AccountConfig, msg *models.MessageInfo,
) (string, error) {
if strings.Contains(s, "{{") && strings.Contains(s, "}}") {
t, err := templates.ParseTemplate("execute", s)
if err != nil {
return "", err
}
data := templateData(cfg, msg)
var buf bytes.Buffer
err = templates.Render(t, &buf, data)
if err != nil {
return "", err
}
s = buf.String()
}
return s, nil
}
func GetTemplateCompletion(
cmd string,
) ([]string, string, bool) {
countLeft := strings.Count(cmd, "{{")
if countLeft == 0 {
return nil, "", false
}
countRight := strings.Count(cmd, "}}")
switch {
case countLeft > countRight:
// complete template terms
var i int
for i = len(cmd) - 1; i >= 0; i-- {
if strings.ContainsRune("{()| ", rune(cmd[i])) {
break
}
}
search, prefix := cmd[i+1:], cmd[:i+1]
padding := strings.Repeat(" ",
len(search)-len(strings.TrimLeft(search, " ")))
options := FilterList(
templates.Terms(),
strings.TrimSpace(search),
nil,
)
return options, prefix + padding, true
case countLeft == countRight:
// expand template
s, err := ExpandTemplates(cmd, nil, nil)
if err != nil {
log.Warnf("template rendering failed: %v", err)
return nil, "", false
}
return []string{s}, "", true
}
return nil, "", false
}
// GetCompletions returns the completion options and the command prefix
func GetCompletions(
cmd Command, args *opt.Args,
) (options []opt.Completion, prefix string) {
// copy zeroed struct
tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
s, err := args.ArgSafe(0)
if err != nil {
log.Errorf("completions error: %v", err)
return options, prefix
}
spec := opt.NewCmdSpec(s, tmp)
return spec.GetCompletions(args)
}
func GetFolders(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return make([]string, 0)
}
return FilterList(acct.Directories().List(), arg, nil)
}
func GetTemplates(arg string) []string {
templates := make(map[string]bool)
for _, dir := range config.Templates.TemplateDirs {
for _, f := range listDir(dir, false) {
if !isDir(path.Join(dir, f)) {
templates[f] = true
}
}
}
names := make([]string, 0, len(templates))
for n := range templates {
names = append(names, n)
}
sort.Strings(names)
return FilterList(names, arg, nil)
}
func GetLabels(arg string) []string {
acct := app.SelectedAccount()
if acct == nil {
return make([]string, 0)
}
var prefix string
if arg != "" {
// + and - are used to denote tag addition / removal and need to
// be striped only the last tag should be completed, so that
// multiple labels can be selected
switch arg[0] {
case '+':
prefix = "+"
case '-':
prefix = "-"
}
arg = strings.TrimLeft(arg, "+-")
}
return FilterList(acct.Labels(), arg, func(s string) string {
return opt.QuoteArg(prefix+s) + " "
})
}
// hasCaseSmartPrefix checks whether s starts with prefix, using a case
// sensitive match if and only if prefix contains upper case letters.
func hasCaseSmartPrefix(s, prefix string) bool {
if hasUpper(prefix) {
return strings.HasPrefix(s, prefix)
}
return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix))
}
func hasUpper(s string) bool {
for _, r := range s {
if unicode.IsUpper(r) {
return true
}
}
return false
}
+79
View File
@@ -0,0 +1,79 @@
package commands
import (
"context"
"fmt"
"net/mail"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/completer"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
)
// GetAddress uses the address-book-cmd for address completion
func GetAddress(search string) []string {
var options []string
cmd := app.SelectedAccount().AccountConfig().AddressBookCmd
if cmd == "" {
cmd = config.Compose.AddressBookCmd
if cmd == "" {
return nil
}
}
cmpl := completer.New(cmd, func(err error) {
app.PushError(
fmt.Sprintf("could not complete header: %v", err))
log.Warnf("could not complete header: %v", err)
})
if cmpl != nil {
addrList, _ := cmpl.ForHeader("to")(context.Background(), search)
for _, full := range addrList {
addr, err := mail.ParseAddress(full.Value)
if err != nil {
continue
}
options = append(options, addr.Address)
}
}
return options
}
// GetFlagList returns a list of available flags for completion
func GetFlagList() []string {
return []string{"Seen", "Answered", "Forwarded", "Flagged", "Draft"}
}
// GetDateList returns a list of date terms for completion
func GetDateList() []string {
return []string{
"today", "yesterday", "this_week", "this_month",
"this_year", "last_week", "last_month", "last_year",
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday",
}
}
// Operands returns a slice without any option flags or mandatory option
// arguments
func Operands(args []string, spec string) []string {
var result []string
for i := 0; i < len(args); i++ {
if s := args[i]; s == "--" {
return args[i+1:]
} else if strings.HasPrefix(s, "-") && len(spec) > 0 {
r := string(s[len(s)-1]) + ":"
if strings.Contains(spec, r) {
i++
}
continue
}
result = append(result, args[i])
}
return result
}
+44
View File
@@ -0,0 +1,44 @@
package commands_test
import (
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/commands"
)
func TestCommands_Operand(t *testing.T) {
tests := []struct {
args []string
spec string
want string
}{
{
args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"},
spec: "ab:c",
want: "cmdbla",
},
{
args: []string{"cmd", "-a", "-b", "arg1", "-c", "--", "bla"},
spec: "ab:c",
want: "bla",
},
{
args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"},
spec: "ab:c:",
want: "cmd",
},
{
args: nil,
spec: "ab:c:",
want: "",
},
}
for i, test := range tests {
arg := strings.Join(commands.Operands(test.args, test.spec), "")
if arg != test.want {
t.Errorf("failed test %d: want '%s', got '%s'", i,
test.want, arg)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package compose
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type Abort struct{}
func init() {
commands.Register(Abort{})
}
func (Abort) Description() string {
return "Close the composer without sending."
}
func (Abort) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Abort) Aliases() []string {
return []string{"abort"}
}
func (Abort) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
app.RemoveTab(composer, true)
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package compose
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type AttachKey struct{}
func init() {
commands.Register(AttachKey{})
}
func (AttachKey) Description() string {
return "Attach the public key of the current account."
}
func (AttachKey) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (AttachKey) Aliases() []string {
return []string{"attach-key"}
}
func (AttachKey) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
return composer.SetAttachKey(!composer.AttachKey())
}
+217
View File
@@ -0,0 +1,217 @@
package compose
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"github.com/pkg/errors"
)
type Attach struct {
Menu bool `opt:"-m" desc:"Select files from file-picker-cmd."`
Name string `opt:"-r" desc:"<name> <cmd...>: Generate attachment from command output."`
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
Args string `opt:"..." required:"false"`
}
func init() {
commands.Register(Attach{})
}
func (Attach) Description() string {
return "Attach the file at the given path to the email."
}
func (Attach) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Attach) Aliases() []string {
return []string{"attach"}
}
func (*Attach) CompletePath(arg string) []string {
return commands.CompletePath(arg, false)
}
func (a Attach) Execute(args []string) error {
if a.Menu && a.Name != "" {
return errors.New("-m and -r are mutually exclusive")
}
switch {
case a.Menu:
return a.openMenu()
case a.Name != "":
if a.Path == "" {
return errors.New("command is required")
}
return a.readCommand()
default:
if a.Args != "" {
return errors.New("only a single path is supported")
}
return a.addPath(a.Path)
}
}
func (a Attach) addPath(path string) error {
path = xdg.ExpandHome(path)
attachments, err := filepath.Glob(path)
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
log.Warnf("failed to parse as globbing pattern: %v", err)
attachments = []string{path}
}
if !strings.HasPrefix(path, ".") && !strings.Contains(path, "/.") {
log.Debugf("removing hidden files from glob results")
for i := len(attachments) - 1; i >= 0; i-- {
if strings.HasPrefix(filepath.Base(attachments[i]), ".") {
if i == len(attachments)-1 {
attachments = attachments[:i]
continue
}
attachments = append(attachments[:i], attachments[i+1:]...)
}
}
}
composer, _ := app.SelectedTabContent().(*app.Composer)
for _, attach := range attachments {
log.Debugf("attaching '%s'", attach)
pathinfo, err := os.Stat(attach)
if err != nil {
log.Errorf("failed to stat file: %v", err)
app.PushError(err.Error())
return err
} else if pathinfo.IsDir() && len(attachments) == 1 {
app.PushError("Attachment must be a file, not a directory")
return nil
}
composer.AddAttachment(attach)
}
if len(attachments) == 1 {
app.PushSuccess(fmt.Sprintf("Attached %s", path))
} else {
app.PushSuccess(fmt.Sprintf("Attached %d files", len(attachments)))
}
return nil
}
func (a Attach) openMenu() error {
filePickerCmd := config.Compose.FilePickerCmd
if filePickerCmd == "" {
return fmt.Errorf("no file-picker-cmd defined")
}
if strings.Contains(filePickerCmd, "%s") {
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%s", a.Path)
}
picks, err := os.CreateTemp("", "aerc-filepicker-*")
if err != nil {
return err
}
var filepicker *exec.Cmd
if strings.Contains(filePickerCmd, "%f") {
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%f", picks.Name())
filepicker = exec.Command("sh", "-c", filePickerCmd)
} else {
filepicker = exec.Command("sh", "-c", filePickerCmd+" >&3")
filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks)
}
t, err := app.NewTerminal(filepicker)
if err != nil {
return err
}
t.Focus(true)
t.OnClose = func(err error) {
defer func() {
if err := picks.Close(); err != nil {
log.Errorf("error closing file: %v", err)
}
if err := os.Remove(picks.Name()); err != nil {
log.Errorf("could not remove tmp file: %v", err)
}
}()
app.CloseDialog()
if err != nil {
log.Errorf("terminal closed with error: %v", err)
return
}
_, err = picks.Seek(0, io.SeekStart)
if err != nil {
log.Errorf("seek failed: %v", err)
return
}
scanner := bufio.NewScanner(picks)
for scanner.Scan() {
f := strings.TrimSpace(scanner.Text())
if _, err := os.Stat(f); err != nil {
continue
}
log.Tracef("File picker attaches: %v", f)
err := a.addPath(f)
if err != nil {
log.Errorf("attach failed for file %s: %v", f, err)
}
}
}
app.AddDialog(app.DefaultDialog(
ui.NewBox(t, "File Picker", "", app.SelectedAccountUiConfig()),
))
return nil
}
func (a Attach) readCommand() error {
cmd := exec.Command("sh", "-c", a.Path+" "+a.Args)
data, err := cmd.Output()
if err != nil {
return errors.Wrap(err, "Output")
}
reader := bufio.NewReader(bytes.NewReader(data))
mimeType, mimeParams, err := lib.FindMimeType(a.Name, reader)
if err != nil {
return errors.Wrap(err, "FindMimeType")
}
mimeParams["name"] = a.Name
composer, _ := app.SelectedTabContent().(*app.Composer)
err = composer.AddPartAttachment(a.Name, mimeType, mimeParams, reader)
if err != nil {
return errors.Wrap(err, "AddPartAttachment")
}
app.PushSuccess(fmt.Sprintf("Attached %s", a.Name))
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package compose
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type CC struct {
Recipients string `opt:"recipients" complete:"CompleteAddress" desc:"Recipient from address book."`
}
func init() {
commands.Register(CC{})
}
func (CC) Description() string {
return "Add the given address(es) to the Cc or Bcc header."
}
func (CC) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (CC) Aliases() []string {
return []string{"cc", "bcc"}
}
func (*CC) CompleteAddress(arg string) []string {
return commands.GetAddress(arg)
}
func (c CC) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
switch args[0] {
case "cc":
return composer.AddEditor("Cc", c.Recipients, true)
case "bcc":
return composer.AddEditor("Bcc", c.Recipients, true)
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package compose
import (
"fmt"
"path/filepath"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"github.com/pkg/errors"
)
type Detach struct {
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
}
func init() {
commands.Register(Detach{})
}
func (Detach) Description() string {
return "Detach the file with the given path from the composed email."
}
func (Detach) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Detach) Aliases() []string {
return []string{"detach"}
}
func (*Detach) CompletePath(arg string) []string {
composer, _ := app.SelectedTabContent().(*app.Composer)
return commands.FilterList(composer.GetAttachments(), arg, nil)
}
func (d Detach) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
if d.Path == "" {
// if no attachment is specified, delete the first in the list
atts := composer.GetAttachments()
if len(atts) > 0 {
d.Path = atts[0]
} else {
return fmt.Errorf("No attachments to delete")
}
}
return d.removePath(d.Path)
}
func (d Detach) removePath(path string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
// If we don't get an error here, the path was not a pattern.
if err := composer.DeleteAttachment(path); err == nil {
log.Debugf("detaching '%s'", path)
app.PushSuccess(fmt.Sprintf("Detached %s", path))
return nil
}
currentAttachments := composer.GetAttachments()
detached := make([]string, 0, len(currentAttachments))
for _, a := range currentAttachments {
// Don't use filepath.Glob like :attach does. Not all files
// that match the glob are already attached to the message.
matches, err := filepath.Match(path, a)
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
log.Warnf("failed to parse as globbing pattern: %v", err)
return err
}
if matches {
log.Debugf("detaching '%s'", a)
if err := composer.DeleteAttachment(a); err != nil {
return err
}
detached = append(detached, a)
}
}
if len(detached) == 1 {
app.PushSuccess(fmt.Sprintf("Detached %s", detached[0]))
} else {
app.PushSuccess(fmt.Sprintf("Detached %d files", len(detached)))
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package compose
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
)
type Edit struct {
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
}
func init() {
commands.Register(Edit{})
}
func (Edit) Description() string {
return "(Re-)open text editor to edit the message in progress."
}
func (Edit) Context() commands.CommandContext {
return commands.COMPOSE_REVIEW
}
func (Edit) Aliases() []string {
return []string{"edit"}
}
func (e Edit) Execute(args []string) error {
composer, ok := app.SelectedTabContent().(*app.Composer)
if !ok {
return errors.New("only valid while composing")
}
editHeaders := (config.Compose.EditHeaders || e.Edit) && !e.NoEdit
err := composer.ShowTerminal(editHeaders)
if err != nil {
return err
}
composer.FocusTerminal()
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package compose
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type Encrypt struct{}
func init() {
commands.Register(Encrypt{})
}
func (Encrypt) Description() string {
return "Toggle encryption of the message to all recipients."
}
func (Encrypt) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Encrypt) Aliases() []string {
return []string{"encrypt"}
}
func (Encrypt) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
composer.SetEncrypt(!composer.Encrypt())
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package compose
import (
"fmt"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type Header struct {
Force bool `opt:"-f" desc:"Overwrite any existing header."`
Remove bool `opt:"-d" desc:"Remove the header instead of adding it."`
Name string `opt:"name" complete:"CompleteHeaders" desc:"Header name."`
Value string `opt:"..." required:"false"`
}
var headers = []string{
"From",
"To",
"Cc",
"Bcc",
"Subject",
"Comments",
"Keywords",
}
func init() {
commands.Register(Header{})
}
func (Header) Description() string {
return "Add or remove the specified email header."
}
func (Header) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Header) Aliases() []string {
return []string{"header"}
}
func (Header) Options() string {
return "fd"
}
func (*Header) CompleteHeaders(arg string) []string {
return commands.FilterList(headers, arg, commands.QuoteSpace)
}
func (h Header) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
name := strings.TrimRight(h.Name, ":")
if h.Remove {
return composer.DelEditor(name)
}
if !h.Force {
headers, err := composer.PrepareHeader()
if err != nil {
return err
}
if headers.Get(name) != "" && h.Value != "" {
return fmt.Errorf(
"Header %s is already set to %q (use -f to overwrite)",
name, headers.Get(name))
}
}
return composer.AddEditor(name, h.Value, false)
}
+66
View File
@@ -0,0 +1,66 @@
package compose
import (
"fmt"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
)
type Multipart struct {
Remove bool `opt:"-d" desc:"Remove the specified mime/type."`
Mime string `opt:"mime" metavar:"<mime/type>" complete:"CompleteMime" desc:"MIME/type name."`
}
func init() {
commands.Register(Multipart{})
}
func (Multipart) Description() string {
return "Convert the message to multipart with the given mime/type part."
}
func (Multipart) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Multipart) Aliases() []string {
return []string{"multipart"}
}
func (*Multipart) CompleteMime(arg string) []string {
var completions []string
for mime := range config.Converters {
completions = append(completions, mime)
}
return commands.FilterList(completions, arg, nil)
}
func (m Multipart) Execute(args []string) error {
composer, ok := app.SelectedTabContent().(*app.Composer)
if !ok {
return fmt.Errorf(":multipart is only available on the compose::review screen")
}
if m.Remove {
return composer.RemovePart(m.Mime)
} else {
_, found := config.Converters[m.Mime]
if !found {
return fmt.Errorf("no command defined for MIME type: %s", m.Mime)
}
err := composer.AppendPart(
m.Mime,
map[string]string{"Charset": "UTF-8"},
// the actual content of the part will be rendered
// every time the body of the email is updated
nil,
)
if err != nil {
return err
}
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package compose
import (
"fmt"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type NextPrevField struct{}
func init() {
commands.Register(NextPrevField{})
}
func (NextPrevField) Description() string {
return "Cycle between header input fields."
}
func (NextPrevField) Context() commands.CommandContext {
return commands.COMPOSE_EDIT
}
func (NextPrevField) Aliases() []string {
return []string{"next-field", "prev-field"}
}
func (NextPrevField) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
var ok bool
if args[0] == "prev-field" {
ok = composer.PrevField()
} else {
ok = composer.NextField()
}
if !ok {
return fmt.Errorf("%s not available when edit-headers=true", args[0])
}
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package compose
import (
"bytes"
"time"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Postpone struct {
Folder string `opt:"-t" complete:"CompleteFolder" desc:"Override the target folder."`
}
func init() {
commands.Register(Postpone{})
}
func (Postpone) Description() string {
return "Save the current state of the message to the postpone folder."
}
func (Postpone) Context() commands.CommandContext {
return commands.COMPOSE_REVIEW
}
func (Postpone) Aliases() []string {
return []string{"postpone"}
}
func (*Postpone) CompleteFolder(arg string) []string {
return commands.GetFolders(arg)
}
func (p Postpone) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := acct.Store()
if store == nil {
return errors.New("No message store selected")
}
tab := app.SelectedTab()
if tab == nil {
return errors.New("No tab selected")
}
composer, _ := tab.Content.(*app.Composer)
config := composer.Config()
tabName := tab.Name
targetFolder := config.Postpone
if composer.RecalledFrom() != "" {
targetFolder = composer.RecalledFrom()
}
if p.Folder != "" {
targetFolder = p.Folder
}
if targetFolder == "" {
return errors.New("No Postpone location configured")
}
log.Tracef("Postponing mail")
header, err := composer.PrepareHeader()
if err != nil {
return errors.Wrap(err, "PrepareHeader")
}
header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})
header.Set("Content-Transfer-Encoding", "quoted-printable")
worker := composer.Worker()
dirs := acct.Directories().List()
alreadyCreated := false
for _, dir := range dirs {
if dir == targetFolder {
alreadyCreated = true
break
}
}
errChan := make(chan string)
// run this as a goroutine so we can make other progress. The message
// will be saved once the directory is created.
go func() {
defer log.PanicHandler()
errStr := <-errChan
if errStr != "" {
app.PushError(errStr)
return
}
handleErr := func(err error) {
app.PushError(err.Error())
log.Errorf("Postponing failed: %v", err)
app.NewTab(composer, tabName)
}
app.RemoveTab(composer, false)
buf := &bytes.Buffer{}
err = composer.WriteMessage(header, buf)
if err != nil {
handleErr(errors.Wrap(err, "WriteMessage"))
return
}
store.Append(
targetFolder,
models.SeenFlag|models.DraftFlag,
time.Now(),
buf,
buf.Len(),
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("Message postponed.", 10*time.Second)
composer.SetPostponed()
composer.Close()
case *types.Error:
handleErr(msg.Error)
}
},
)
}()
if !alreadyCreated {
// to synchronise the creating of the directory
worker.PostAction(&types.CreateDirectory{
Directory: targetFolder,
}, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
errChan <- ""
case *types.Error:
errChan <- msg.Error.Error()
}
})
} else {
errChan <- ""
}
return nil
}
+328
View File
@@ -0,0 +1,328 @@
package compose
import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/mode"
"git.sr.ht/~rjarry/aerc/commands/msg"
"git.sr.ht/~rjarry/aerc/lib/hooks"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/send"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
"github.com/emersion/go-message/mail"
)
type Send struct {
Archive string `opt:"-a" action:"ParseArchive" metavar:"flat|year|month" complete:"CompleteArchive" desc:"Archive the message being replied to."`
CopyTo []string `opt:"-t" complete:"CompleteFolders" action:"ParseCopyTo" desc:"Override the Copy-To folders."`
CopyToReplied bool `opt:"-r" desc:"Save sent message to current folder."`
NoCopyToReplied bool `opt:"-R" desc:"Do not save sent message to current folder."`
}
func init() {
commands.Register(Send{})
}
func (Send) Description() string {
return "Send the message using the configured outgoing transport."
}
func (Send) Context() commands.CommandContext {
return commands.COMPOSE_REVIEW
}
func (Send) Aliases() []string {
return []string{"send"}
}
func (*Send) CompleteArchive(arg string) []string {
return commands.FilterList(msg.ARCHIVE_TYPES, arg, nil)
}
func (*Send) CompleteFolders(arg string) []string {
return commands.GetFolders(arg)
}
func (s *Send) ParseArchive(arg string) error {
for _, a := range msg.ARCHIVE_TYPES {
if a == arg {
s.Archive = arg
return nil
}
}
return errors.New("unsupported archive type")
}
func (o *Send) ParseCopyTo(arg string) error {
o.CopyTo = append(o.CopyTo, strings.Split(arg, ",")...)
return nil
}
func (s Send) Execute(args []string) error {
tab := app.SelectedTab()
if tab == nil {
return errors.New("No selected tab")
}
composer, _ := tab.Content.(*app.Composer)
err := composer.CheckForMultipartErrors()
if err != nil {
return err
}
config := composer.Config()
if len(s.CopyTo) == 0 {
s.CopyTo = config.CopyTo
}
copyToReplied := config.CopyToReplied || (s.CopyToReplied && !s.NoCopyToReplied)
outgoing, err := config.Outgoing.ConnectionString()
if err != nil {
return errors.Wrap(err, "ReadCredentials(outgoing)")
}
if outgoing == "" {
return errors.New(
"No outgoing mail transport configured for this account")
}
header, err := composer.PrepareHeader()
if err != nil {
return errors.Wrap(err, "PrepareHeader")
}
rcpts, err := listRecipients(header)
if err != nil {
return errors.Wrap(err, "listRecipients")
}
if len(rcpts) == 0 {
return errors.New("Cannot send message with no recipients")
}
if config.StripBcc {
// Do NOT leak Bcc addresses to all recipients.
header.Del("Bcc")
}
uri, err := url.Parse(outgoing)
if err != nil {
return errors.Wrap(err, "url.Parse(outgoing)")
}
var domain string
if domain_, ok := config.Params["smtp-domain"]; ok {
domain = domain_
}
from := config.From
if config.UseEnvelopeFrom {
if fl, _ := header.AddressList("from"); len(fl) != 0 {
from = fl[0]
}
}
log.Debugf("send config uri: %s", uri.Redacted())
log.Debugf("send config from: %s", from)
log.Debugf("send config rcpts: %s", rcpts)
log.Debugf("send config domain: %s", domain)
warnSubject := composer.ShouldWarnSubject()
warnAttachment := composer.ShouldWarnAttachment()
if warnSubject || warnAttachment {
var msg string
switch {
case warnSubject && warnAttachment:
msg = "The subject is empty, and you may have forgotten an attachment."
case warnSubject:
msg = "The subject is empty."
default:
msg = "You may have forgotten an attachment."
}
prompt := app.NewPrompt(
msg+" Abort send? [Y/n] ",
func(text string) {
if text == "n" || text == "N" {
sendHelper(composer, header, uri, domain,
from, rcpts, tab.Name, s.CopyTo,
s.Archive, copyToReplied)
}
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
var comps []opt.Completion
if cmd == "" {
comps = append(comps, opt.Completion{Value: "y"})
comps = append(comps, opt.Completion{Value: "n"})
}
return comps, ""
},
)
app.PushPrompt(prompt)
} else {
sendHelper(composer, header, uri, domain, from, rcpts, tab.Name,
s.CopyTo, s.Archive, copyToReplied)
}
return nil
}
func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domain string,
from *mail.Address, rcpts []*mail.Address, tabName string, copyTo []string,
archive string, copyToReplied bool,
) {
// we don't want to block the UI thread while we are sending
// so we do everything in a goroutine and hide the composer from the user
app.RemoveTab(composer, false)
app.PushStatus("Sending...", 10*time.Second)
// enter no-quit mode
mode.NoQuit()
var shouldCopy bool = (len(copyTo) > 0 || copyToReplied) && !strings.HasPrefix(uri.Scheme, "jmap")
var copyBuf bytes.Buffer
failCh := make(chan error)
// writer
go func() {
defer log.PanicHandler()
var folders []string
folders = append(folders, copyTo...)
if copyToReplied && composer.Parent() != nil {
folders = append(folders, composer.Parent().Folder)
}
sender, err := send.NewSender(
composer.Worker(), uri, domain, from, rcpts, folders)
if err != nil {
failCh <- errors.Wrap(err, "send:")
return
}
var writer io.Writer = sender
if shouldCopy {
writer = io.MultiWriter(writer, &copyBuf)
}
err = composer.WriteMessage(header, writer)
if err != nil {
failCh <- err
return
}
failCh <- sender.Close()
}()
// cleanup + copy to sent
go func() {
defer log.PanicHandler()
// leave no-quit mode
defer mode.NoQuitDone()
err := <-failCh
if err != nil {
app.PushError(strings.ReplaceAll(err.Error(), "\n", " "))
app.NewTab(composer, tabName)
return
}
if shouldCopy {
app.PushStatus("Copying to copy-to folders", 10*time.Second)
errch := copyToSent(copyTo, copyToReplied, copyBuf.Len(),
&copyBuf, composer)
err = <-errch
if err != nil {
errmsg := fmt.Sprintf(
"message sent, but copying to %v failed: %v",
copyTo, err.Error())
app.PushError(errmsg)
composer.SetSent(archive)
composer.Close()
return
}
}
app.PushStatus("Message sent.", 10*time.Second)
composer.SetSent(archive)
err = hooks.RunHook(&hooks.MailSent{
Account: composer.Account().Name(),
Backend: composer.Account().AccountConfig().Backend,
Header: header,
})
if err != nil {
log.Errorf("failed to trigger mail-sent hook: %v", err)
composer.Account().PushError(fmt.Errorf("[hook.mail-sent] failed: %w", err))
}
composer.Close()
}()
}
func listRecipients(h *mail.Header) ([]*mail.Address, error) {
var rcpts []*mail.Address
for _, key := range []string{"to", "cc", "bcc"} {
list, err := h.AddressList(key)
if err != nil {
return nil, err
}
rcpts = append(rcpts, list...)
}
return rcpts, nil
}
func copyToSent(dests []string, copyToReplied bool, n int, msg *bytes.Buffer, composer *app.Composer) <-chan error {
errCh := make(chan error, 1)
acct := composer.Account()
if acct == nil {
errCh <- errors.New("No account selected")
return errCh
}
store := acct.Store()
if store == nil {
errCh <- errors.New("No message store selected")
return errCh
}
for _, dest := range dests {
store.Append(
dest,
models.SeenFlag,
time.Now(),
bytes.NewReader(msg.Bytes()),
n,
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
errCh <- nil
case *types.Error:
errCh <- msg.Error
}
},
)
}
if copyToReplied && composer.Parent() != nil {
store.Append(
composer.Parent().Folder,
models.SeenFlag,
time.Now(),
bytes.NewReader(msg.Bytes()),
n,
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
errCh <- nil
case *types.Error:
errCh <- msg.Error
}
},
)
}
return errCh
}
+47
View File
@@ -0,0 +1,47 @@
package compose
import (
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type Sign struct{}
func init() {
commands.Register(Sign{})
}
func (Sign) Description() string {
return "Sign the message using the account default key."
}
func (Sign) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (Sign) Aliases() []string {
return []string{"sign"}
}
func (Sign) Execute(args []string) error {
composer, _ := app.SelectedTabContent().(*app.Composer)
err := composer.SetSign(!composer.Sign())
if err != nil {
return err
}
var statusline string
if composer.Sign() {
statusline = "Message will be signed."
} else {
statusline = "Message will not be signed."
}
app.PushStatus(statusline, 10*time.Second)
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package compose
import (
"errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type AccountSwitcher interface {
SwitchAccount(*app.AccountView) error
}
type SwitchAccount struct {
Prev bool `opt:"-p" desc:"Switch to previous account."`
Next bool `opt:"-n" desc:"Switch to next account."`
Account string `opt:"account" required:"false" complete:"CompleteAccount" desc:"Account name."`
}
func init() {
commands.Register(SwitchAccount{})
}
func (SwitchAccount) Description() string {
return "Change composing from the specified account."
}
func (SwitchAccount) Context() commands.CommandContext {
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
}
func (SwitchAccount) Aliases() []string {
return []string{"switch-account"}
}
func (*SwitchAccount) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, nil)
}
func (s SwitchAccount) Execute(args []string) error {
if !s.Prev && !s.Next && s.Account == "" {
return errors.New("Usage: switch-account -n | -p | <account-name>")
}
switcher, ok := app.SelectedTabContent().(AccountSwitcher)
if !ok {
return errors.New("this tab cannot switch accounts")
}
var acct *app.AccountView
var err error
switch {
case s.Prev:
acct, err = app.PrevAccount()
case s.Next:
acct, err = app.NextAccount()
default:
acct, err = app.Account(s.Account)
}
if err != nil {
return err
}
if err = switcher.SwitchAccount(acct); err != nil {
return err
}
acct.UpdateStatus()
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package commands
import (
"errors"
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/app"
)
type ChangeTab struct {
Tab string `opt:"tab" complete:"CompleteTab" desc:"Tab name."`
}
func init() {
Register(ChangeTab{})
}
func (ChangeTab) Description() string {
return "Change the focus to the specified tab."
}
func (ChangeTab) Context() CommandContext {
return GLOBAL
}
func (ChangeTab) Aliases() []string {
return []string{"ct", "change-tab"}
}
func (*ChangeTab) CompleteTab(arg string) []string {
return FilterList(app.TabNames(), arg, nil)
}
func (c ChangeTab) Execute(args []string) error {
if c.Tab == "-" {
ok := app.SelectPreviousTab()
if !ok {
return errors.New("No previous tab to return to")
}
} else {
n, err := strconv.Atoi(c.Tab)
if err == nil {
if strings.HasPrefix(c.Tab, "+") || strings.HasPrefix(c.Tab, "-") {
app.SelectTabAtOffset(n)
} else {
ok := app.SelectTabIndex(n)
if !ok {
return errors.New("No tab with that index")
}
}
} else {
ok := app.SelectTab(c.Tab)
if !ok {
return errors.New("No tab with that name")
}
}
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package commands
import (
"git.sr.ht/~rjarry/aerc/app"
)
type Echo struct {
Template string `opt:"..." required:"false"`
}
func init() {
Register(Echo{})
}
func (Echo) Description() string {
return "Print text after template expansion."
}
func (Echo) Aliases() []string {
return []string{"echo"}
}
func (Echo) Context() CommandContext {
return GLOBAL
}
func (e Echo) Execute(args []string) error {
app.PushSuccess(e.Template)
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package commands
import (
"bytes"
"fmt"
"io"
"os"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
type Eml struct {
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"EML file path."`
}
func init() {
Register(Eml{})
}
func (Eml) Description() string {
return "Open an eml file into the message viewer."
}
func (Eml) Context() CommandContext {
return GLOBAL
}
func (Eml) Aliases() []string {
return []string{"eml", "preview"}
}
func (*Eml) CompletePath(arg string) []string {
return CompletePath(arg, false)
}
func (e Eml) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return fmt.Errorf("no account selected")
}
showEml := func(r io.Reader) {
data, err := io.ReadAll(r)
if err != nil {
app.PushError(err.Error())
return
}
lib.NewEmlMessageView(data, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
msgView, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.NewTab(msgView,
view.MessageInfo().Envelope.Subject)
})
}
if e.Path == "" {
switch tab := app.SelectedTabContent().(type) {
case *app.MessageViewer:
part := tab.SelectedMessagePart()
tab.MessageView().FetchBodyPart(part.Index, showEml)
case *app.Composer:
var buf bytes.Buffer
h, err := tab.PrepareHeader()
if err != nil {
return err
}
if err := tab.WriteMessage(h, &buf); err != nil {
return err
}
showEml(&buf)
default:
return fmt.Errorf("unsupported operation")
}
} else {
f, err := os.Open(xdg.ExpandHome(e.Path))
if err != nil {
return err
}
defer f.Close()
showEml(f)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package commands
import (
"fmt"
"os"
"os/exec"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/lib/log"
)
type ExecCmd struct {
Args []string `opt:"..."`
}
func init() {
Register(ExecCmd{})
}
func (ExecCmd) Description() string {
return "Execute an arbitrary command in the background."
}
func (ExecCmd) Context() CommandContext {
return GLOBAL
}
func (ExecCmd) Aliases() []string {
return []string{"exec"}
}
func (e ExecCmd) Execute(args []string) error {
cmd := exec.Command(e.Args[0], e.Args[1:]...)
env := os.Environ()
switch view := app.SelectedTabContent().(type) {
case *app.AccountView:
env = append(env, fmt.Sprintf("account=%s", view.AccountConfig().Name))
env = append(env, fmt.Sprintf("folder=%s", view.Directories().Selected()))
case *app.MessageViewer:
acct := view.SelectedAccount()
env = append(env, fmt.Sprintf("account=%s", acct.AccountConfig().Name))
env = append(env, fmt.Sprintf("folder=%s", acct.Directories().Selected()))
}
cmd.Env = env
go func() {
defer log.PanicHandler()
err := cmd.Run()
if err != nil {
app.PushError(err.Error())
} else {
if cmd.ProcessState.ExitCode() != 0 {
app.PushError(fmt.Sprintf(
"%s: completed with status %d", args[0],
cmd.ProcessState.ExitCode()))
} else {
app.PushStatus(fmt.Sprintf(
"%s: completed with status %d", args[0],
cmd.ProcessState.ExitCode()), 10*time.Second)
}
}
}()
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package commands
import (
"fmt"
"git.sr.ht/~rjarry/aerc/app"
)
type Help struct {
Topic string `opt:"topic" action:"ParseTopic" default:"aerc" complete:"CompleteTopic" desc:"Help topic."`
}
var pages = []string{
"aerc",
"accounts",
"binds",
"config",
"imap",
"jmap",
"notmuch",
"search",
"sendmail",
"smtp",
"stylesets",
"templates",
"tutorial",
"patch",
"keys",
}
func init() {
Register(Help{})
}
func (Help) Description() string {
return "Display one of aerc's man pages in the embedded terminal."
}
func (Help) Context() CommandContext {
return GLOBAL
}
func (Help) Aliases() []string {
return []string{"help", "man"}
}
func (*Help) CompleteTopic(arg string) []string {
return FilterList(pages, arg, nil)
}
func (h *Help) ParseTopic(arg string) error {
for _, page := range pages {
if arg == page {
if arg != "aerc" {
arg = "aerc-" + arg
}
h.Topic = arg
return nil
}
}
return fmt.Errorf("unknown topic %q", arg)
}
func (h Help) Execute(args []string) error {
if h.Topic == "aerc-keys" {
app.AddDialog(app.DefaultDialog(
app.NewListBox(
"Bindings: Press <Esc> or <Enter> to close. "+
"Start typing to filter bindings.",
app.HumanReadableBindings(),
app.SelectedAccountUiConfig(),
func(_ string) {
app.CloseDialog()
},
),
))
return nil
}
term := Term{Cmd: []string{"man", h.Topic}}
return term.Execute(args)
}
+140
View File
@@ -0,0 +1,140 @@
package commands
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"sync"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
)
type cmdHistory struct {
// rolling buffer of prior commands
//
// most recent command is at the end of the list,
// least recent is index 0
cmdList []string
// current placement in list
current int
// initialize history storage
initHistfile sync.Once
histfile io.ReadWriter
}
// number of commands to keep in history
const cmdLimit = 1000
// CmdHistory is the history of executed commands
var CmdHistory = cmdHistory{}
func (h *cmdHistory) Add(cmd string) {
h.initHistfile.Do(h.initialize)
// if we're at cap, cut off the first element
if len(h.cmdList) >= cmdLimit {
h.cmdList = h.cmdList[1:]
}
if len(h.cmdList) == 0 || h.cmdList[len(h.cmdList)-1] != cmd {
h.cmdList = append(h.cmdList, cmd)
h.writeHistory()
}
// whenever we add a new command, reset the current
// pointer to the "beginning" of the list
h.Reset()
}
// Prev returns the previous command in history.
// Since the list is reverse-order, this will return elements
// increasingly towards index 0.
func (h *cmdHistory) Prev() string {
h.initHistfile.Do(h.initialize)
if h.current <= 0 || len(h.cmdList) == 0 {
h.current = -1
return "(Already at beginning)"
}
h.current--
return h.cmdList[h.current]
}
// Next returns the next command in history.
// Since the list is reverse-order, this will return elements
// increasingly towards index len(cmdList).
func (h *cmdHistory) Next() string {
h.initHistfile.Do(h.initialize)
if h.current >= len(h.cmdList)-1 || len(h.cmdList) == 0 {
h.current = len(h.cmdList)
return "(Already at end)"
}
h.current++
return h.cmdList[h.current]
}
// Reset the current pointer to the beginning of history.
func (h *cmdHistory) Reset() {
h.current = len(h.cmdList)
}
func (h *cmdHistory) initialize() {
var err error
openFlags := os.O_RDWR | os.O_EXCL
histPath := xdg.StatePath("aerc", "history")
if _, err := os.Stat(histPath); os.IsNotExist(err) {
_ = os.MkdirAll(xdg.StatePath("aerc"), 0o700) // caught by OpenFile
openFlags |= os.O_CREATE
}
// O_EXCL to make sure that only one aerc writes to the file
h.histfile, err = os.OpenFile(
histPath,
openFlags,
0o600,
)
if err != nil {
log.Errorf("failed to open history file: %v", err)
// basically mirror the old behavior
h.histfile = bytes.NewBuffer([]byte{})
return
}
s := bufio.NewScanner(h.histfile)
for s.Scan() {
h.cmdList = append(h.cmdList, s.Text())
}
h.Reset()
}
func (h *cmdHistory) writeHistory() {
if fh, ok := h.histfile.(*os.File); ok {
err := fh.Truncate(0)
if err != nil {
// if we can't delete it, don't break it.
return
}
_, err = fh.Seek(0, io.SeekStart)
if err != nil {
// if we can't delete it, don't break it.
return
}
for _, entry := range h.cmdList {
fmt.Fprintln(fh, entry)
}
fh.Sync() //nolint:errcheck // if your computer can't sync you're in bigger trouble
}
}
+232
View File
@@ -0,0 +1,232 @@
package commands
import (
"errors"
"io"
"os"
"os/exec"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/go-opt/v2"
)
type Menu struct {
ErrExit bool `opt:"-e" desc:"Stop executing commands on the first error."`
Background bool `opt:"-b" desc:"Do NOT spawn the popover dialog."`
Accounts bool `opt:"-a" desc:"Feed command with account names."`
Directories bool `opt:"-d" desc:"Feed command with folder names."`
Command string `opt:"-c" desc:"Override [general].default-menu-cmd."`
Xargs string `opt:"..." complete:"CompleteXargs" desc:"Command name."`
}
func init() {
Register(Menu{})
}
func (Menu) Description() string {
return "Open a popover dialog."
}
func (Menu) Context() CommandContext {
return GLOBAL
}
func (Menu) Aliases() []string {
return []string{"menu"}
}
func (*Menu) CompleteXargs(arg string) []string {
return FilterList(ActiveCommandNames(), arg, nil)
}
func (m Menu) Execute([]string) error {
if m.Command == "" {
m.Command = config.General.DefaultMenuCmd
}
useFallback := m.useFallback()
if m.Background && useFallback {
return errors.New("Either -c <command> or " +
"default-menu-cmd is required to run " +
"in the background.")
}
if _, _, err := ResolveCommand(m.Xargs, nil, nil); err != nil {
return err
}
lines, err := m.feedLines()
if err != nil {
return err
}
title := " :" + strings.TrimLeft(m.Xargs, ": \t") + " ... "
if useFallback {
return m.fallback(title, lines)
}
pick, err := os.CreateTemp("", "aerc-menu-*")
if err != nil {
return err
}
var proc *exec.Cmd
if strings.Contains(m.Command, "%f") {
proc = exec.Command("sh", "-c",
strings.ReplaceAll(m.Command, "%f", opt.QuoteArg(pick.Name())))
} else {
proc = exec.Command("sh", "-c", m.Command+" >&3")
proc.ExtraFiles = append(proc.ExtraFiles, pick)
}
if len(lines) > 0 {
proc.Stdin = strings.NewReader(strings.Join(lines, "\n"))
}
xargs := func(err error) {
var buf []byte
if err == nil {
_, err = pick.Seek(0, io.SeekStart)
}
if err == nil {
buf, err = io.ReadAll(pick)
}
pick.Close()
os.Remove(pick.Name())
if err != nil {
app.PushError("command failed: " + err.Error())
return
}
if len(buf) == 0 {
return
}
m.runCmd(string(buf))
}
if m.Background {
go func() {
defer log.PanicHandler()
xargs(proc.Run())
}()
} else {
term, err := app.NewTerminal(proc)
if err != nil {
return err
}
term.Focus(true)
term.OnClose = func(err error) {
app.CloseDialog()
xargs(err)
}
widget := ui.NewBox(term, title, "", app.SelectedAccountUiConfig())
app.AddDialog(app.DefaultDialog(widget))
}
return nil
}
func (m Menu) useFallback() bool {
if m.Command == "" || m.Command == "-" {
warnMsg := "no command provided, falling back on aerc's picker."
log.Warnf(warnMsg)
app.PushWarning(warnMsg)
return true
}
cmd, _, _ := strings.Cut(m.Command, " ")
_, err := exec.LookPath(cmd)
if err != nil {
warnMsg := "command '" + cmd + "' not found in PATH, " +
"falling back on aerc's picker."
log.Warnf(warnMsg)
app.PushWarning(warnMsg)
return true
}
return false
}
func (m Menu) runCmd(buffer string) {
var (
cmd Command
cmdline string
err error
)
for _, line := range strings.Split(buffer, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cmdline = m.Xargs + " " + line
cmdline, cmd, err = ResolveCommand(cmdline, nil, nil)
if err == nil {
err = ExecuteCommand(cmd, cmdline)
}
if err != nil {
app.PushError(m.Xargs + ": " + err.Error())
if m.ErrExit {
return
}
}
}
}
func (m Menu) fallback(title string, lines []string) error {
listBox := app.NewListBox(
title, lines, app.SelectedAccountUiConfig(),
func(line string) {
app.CloseDialog()
if line == "" {
return
}
m.runCmd(line)
})
listBox.SetTextFilter(func(list []string, term string) []string {
return FilterList(list, term, func(s string) string { return s })
})
widget := ui.NewBox(listBox, "", "", app.SelectedAccountUiConfig())
app.AddDialog(app.DefaultDialog(widget))
return nil
}
func (m Menu) feedLines() ([]string, error) {
var lines []string
switch {
case m.Accounts && m.Directories:
for _, a := range app.AccountNames() {
account, _ := app.Account(a)
a = opt.QuoteArg(a)
for _, d := range account.Directories().List() {
dir := account.Directories().Directory(d)
if dir != nil && dir.Role != models.QueryRole {
d = opt.QuoteArg(d)
}
lines = append(lines, a+" "+d)
}
}
case m.Accounts:
for _, account := range app.AccountNames() {
lines = append(lines, opt.QuoteArg(account))
}
case m.Directories:
account := app.SelectedAccount()
if account == nil {
return nil, errors.New("No account selected.")
}
for _, d := range account.Directories().List() {
dir := account.Directories().Directory(d)
if dir != nil && dir.Role != models.QueryRole {
d = opt.QuoteArg(d)
}
lines = append(lines, d)
}
}
return lines, nil
}
+23
View File
@@ -0,0 +1,23 @@
package mode
import "sync/atomic"
// noquit is a counter for goroutines that requested the no-quit mode
var noquit int32
// NoQuit enters no-quit mode where aerc cannot be exited (unless the force
// option is used)
func NoQuit() {
atomic.AddInt32(&noquit, 1)
}
// NoQuitDone leaves the no-quit mode
func NoQuitDone() {
atomic.AddInt32(&noquit, -1)
}
// QuitAllowed checks if aerc can exit normally (only when all goroutines that
// requested a no-quit mode were done and called the NoQuitDone() function)
func QuitAllowed() bool {
return atomic.LoadInt32(&noquit) <= 0
}
+46
View File
@@ -0,0 +1,46 @@
package commands
import (
"strconv"
"strings"
"git.sr.ht/~rjarry/aerc/app"
)
type MoveTab struct {
Index int `opt:"index" metavar:"[+|-]<index>" action:"ParseIndex"`
Relative bool
}
func init() {
Register(MoveTab{})
}
func (MoveTab) Description() string {
return "Move the selected tab to the given index."
}
func (MoveTab) Context() CommandContext {
return GLOBAL
}
func (m *MoveTab) ParseIndex(arg string) error {
i, err := strconv.ParseInt(arg, 10, 64)
if err != nil {
return err
}
m.Index = int(i)
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
m.Relative = true
}
return nil
}
func (MoveTab) Aliases() []string {
return []string{"move-tab"}
}
func (m MoveTab) Execute(args []string) error {
app.MoveTab(m.Index, m.Relative)
return nil
}
+178
View File
@@ -0,0 +1,178 @@
package msg
import (
"fmt"
"strings"
"sync"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
const (
ARCHIVE_FLAT = "flat"
ARCHIVE_YEAR = "year"
ARCHIVE_MONTH = "month"
)
var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH}
type Archive struct {
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType" desc:"Archiving scheme."`
}
func (a *Archive) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
a.MultiFileStrategy = &mfs
}
return nil
}
func (a *Archive) ParseArchiveType(arg string) error {
for _, t := range ARCHIVE_TYPES {
if t == arg {
a.Type = arg
return nil
}
}
return fmt.Errorf("invalid archive type")
}
func init() {
commands.Register(Archive{})
}
func (Archive) Description() string {
return "Move the selected message to the archive."
}
func (Archive) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Archive) Aliases() []string {
return []string{"archive"}
}
func (Archive) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (*Archive) CompleteType(arg string) []string {
return commands.FilterList(ARCHIVE_TYPES, arg, nil)
}
func (a Archive) Execute(args []string) error {
h := newHelper()
msgs, err := h.messages()
if err != nil {
return err
}
err = archive(msgs, a.MultiFileStrategy, a.Type)
return err
}
func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy,
archiveType string,
) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
var uids []models.UID
for _, msg := range msgs {
uids = append(uids, msg.Uid)
}
archiveDir := acct.AccountConfig().Archive
marker := store.Marker()
marker.ClearVisualMark()
next := findNextNonDeleted(uids, store)
var uidMap map[string][]models.UID
switch archiveType {
case ARCHIVE_MONTH:
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
dir := strings.Join([]string{
archiveDir,
fmt.Sprintf("%d", msg.Envelope.Date.Year()),
fmt.Sprintf("%02d", msg.Envelope.Date.Month()),
}, app.SelectedAccount().Worker().PathSeparator(),
)
return dir
})
case ARCHIVE_YEAR:
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
dir := strings.Join([]string{
archiveDir,
fmt.Sprintf("%v", msg.Envelope.Date.Year()),
}, app.SelectedAccount().Worker().PathSeparator(),
)
return dir
})
case ARCHIVE_FLAT:
uidMap = make(map[string][]models.UID)
uidMap[archiveDir] = commands.UidsFromMessageInfos(msgs)
}
var wg sync.WaitGroup
wg.Add(len(uidMap))
success := true
for dir, uids := range uidMap {
store.Move(uids, dir, true, mfs, func(
msg types.WorkerMessage,
) {
switch msg := msg.(type) {
case *types.Done:
wg.Done()
case *types.Error:
app.PushError(msg.Error.Error())
success = false
wg.Done()
marker.Remark()
}
})
}
// we need to do that in the background, else we block the main thread
go func() {
defer log.PanicHandler()
wg.Wait()
if success {
var s string
if len(uids) > 1 {
s = "%d messages archived to %s"
} else {
s = "%d message archived to %s"
}
app.PushStatus(fmt.Sprintf(s, len(uids), archiveDir), 10*time.Second)
handleDone(acct, next, store)
}
}()
return nil
}
func groupBy(msgs []*models.MessageInfo,
grouper func(*models.MessageInfo) string,
) map[string][]models.UID {
m := make(map[string][]models.UID)
for _, msg := range msgs {
group := grouper(msg)
m[group] = append(m[group], msg.Uid)
}
return m
}
+214
View File
@@ -0,0 +1,214 @@
package msg
import (
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/emersion/go-message/mail"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/mode"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/send"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Bounce struct {
Account string `opt:"-A" complete:"CompleteAccount" desc:"Account from which to re-send the message."`
To []string `opt:"..." required:"true" complete:"CompleteTo" desc:"Recipient from address book."`
}
func init() {
commands.Register(Bounce{})
}
func (Bounce) Description() string {
return "Re-send the selected message(s) to the specified addresses."
}
func (Bounce) Aliases() []string {
return []string{"bounce", "resend"}
}
func (*Bounce) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (*Bounce) CompleteTo(arg string) []string {
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
}
func (Bounce) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (b Bounce) Execute(args []string) error {
if len(b.To) == 0 {
return errors.New("No recipients specified")
}
addresses := strings.Join(b.To, ", ")
app.PushStatus("Bouncing to "+addresses, 10*time.Second)
widget := app.SelectedTabContent().(app.ProvidesMessage)
var err error
acct := widget.SelectedAccount()
if b.Account != "" {
acct, err = app.Account(b.Account)
}
switch {
case err != nil:
return fmt.Errorf("Failed to select account %q: %w", b.Account, err)
case acct == nil:
return errors.New("No account selected")
}
store := widget.Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
config := acct.AccountConfig()
outgoing, err := config.Outgoing.ConnectionString()
if err != nil {
return errors.Wrap(err, "ReadCredentials()")
}
if outgoing == "" {
return errors.New("No outgoing mail transport configured for this account")
}
uri, err := url.Parse(outgoing)
if err != nil {
return errors.Wrap(err, "url.Parse()")
}
rcpts, err := mail.ParseAddressList(addresses)
if err != nil {
return errors.Wrap(err, "ParseAddressList()")
}
var domain string
if domain_, ok := config.Params["smtp-domain"]; ok {
domain = domain_
}
hostname, err := send.GetMessageIdHostname(config.SendWithHostname, config.From)
if err != nil {
return errors.Wrap(err, "GetMessageIdHostname()")
}
// According to RFC2822, all of the resent fields corresponding
// to a particular resending of the message SHOULD be together.
// Each new set of resent fields is prepended to the message;
// that is, the most recent set of resent fields appear earlier in the
// message.
headers := fmt.Sprintf("Resent-From: %s\r\n", config.From)
headers += "Resent-Date: %s\r\n"
headers += "Resent-Message-ID: <%s>\r\n"
headers += fmt.Sprintf("Resent-To: %s\r\n", addresses)
helper := newHelper()
uids, err := helper.markedOrSelectedUids()
if err != nil {
return err
}
mode.NoQuit()
marker := store.Marker()
marker.ClearVisualMark()
errCh := make(chan error)
store.FetchFull(uids, func(fm *types.FullMessage) {
defer log.PanicHandler()
var header mail.Header
var msgId string
var err, errClose error
uid := fm.Content.Uid
msg := store.Messages[uid]
if msg == nil {
errCh <- fmt.Errorf("no message info: %v", uid)
return
}
if err = header.GenerateMessageIDWithHostname(hostname); err != nil {
errCh <- errors.Wrap(err, "GenerateMessageIDWithHostname()")
return
}
if msgId, err = header.MessageID(); err != nil {
errCh <- errors.Wrap(err, "MessageID()")
return
}
reader := strings.NewReader(fmt.Sprintf(headers,
time.Now().Format(time.RFC1123Z), msgId))
go func() {
defer log.PanicHandler()
defer func() { errCh <- err }()
var sender io.WriteCloser
log.Debugf("Bouncing email <%s> to %s",
msg.Envelope.MessageId, addresses)
if sender, err = send.NewSender(acct.Worker(), uri,
domain, config.From, rcpts, nil); err != nil {
return
}
defer func() {
errClose = sender.Close()
// If there has already been an error,
// we don't want to clobber it.
if err == nil {
err = errClose
} else if errClose != nil {
app.PushError(errClose.Error())
}
}()
if _, err = io.Copy(sender, reader); err != nil {
return
}
_, err = io.Copy(sender, fm.Content.Reader)
}()
})
go func() {
defer log.PanicHandler()
defer mode.NoQuitDone()
var total, success int
for err = range errCh {
if err != nil {
app.PushError(err.Error())
} else {
success++
}
total++
if total == len(uids) {
break
}
}
if success != total {
marker.Remark()
app.PushError(fmt.Sprintf("Failed to bounce %d of the messages",
total-success))
} else {
plural := ""
if success > 1 {
plural = "s"
}
app.PushStatus(fmt.Sprintf("Bounced %d message%s",
success, plural), 10*time.Second)
}
}()
return nil
}
+201
View File
@@ -0,0 +1,201 @@
package msg
import (
"bytes"
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-message/mail"
"github.com/pkg/errors"
)
type Copy struct {
CreateFolders bool `opt:"-p" desc:"Create folder if it does not exist."`
Decrypt bool `opt:"-d" desc:"Decrypt the message before copying."`
Account string `opt:"-a" complete:"CompleteAccount" desc:"Copy to the specified account."`
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
}
func init() {
commands.Register(Copy{})
}
func (Copy) Description() string {
return "Copy the selected message(s) to the specified folder."
}
func (Copy) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Copy) Aliases() []string {
return []string{"cp", "copy"}
}
func (c *Copy) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
c.MultiFileStrategy = &mfs
}
return nil
}
func (*Copy) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (c *Copy) CompleteFolder(arg string) []string {
var acct *app.AccountView
if len(c.Account) > 0 {
acct, _ = app.Account(c.Account)
} else {
acct = app.SelectedAccount()
}
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, nil)
}
func (Copy) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (c Copy) Execute(args []string) error {
h := newHelper()
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
// when the decrypt flag is set, add the current account to c.Account to
// ensure that we do not take the store.Copy route.
if c.Decrypt {
if acct := app.SelectedAccount(); acct != nil {
c.Account = acct.Name()
} else {
return errors.New("no account name found")
}
}
if len(c.Account) == 0 {
store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy,
func(msg types.WorkerMessage) {
c.CallBack(msg, uids, store)
})
return nil
}
destAcct, err := app.Account(c.Account)
if err != nil {
return err
}
destStore := destAcct.Store()
if destStore == nil {
app.PushError(fmt.Sprintf("No message store in %s", c.Account))
return nil
}
var messages []*types.FullMessage
fetchDone := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
if fm == nil {
return
}
if c.Decrypt {
h := new(mail.Header)
msg, ok := store.Messages[fm.Content.Uid]
if ok {
h = msg.RFC822Headers
}
cleartext, err := cryptoutil.Cleartext(fm.Content.Reader, *h)
if err != nil {
log.Debugf("could not decrypt message %v", fm.Content.Uid)
} else {
fm.Content.Reader = bytes.NewReader(cleartext)
}
}
messages = append(messages, fm)
if len(messages) == len(uids) {
fetchDone <- true
}
})
// Since this operation can take some time with some backends
// (e.g. IMAP), provide some feedback to inform the user that
// something is happening
app.PushStatus("Copying messages...", 10*time.Second)
go func() {
defer log.PanicHandler()
select {
case <-fetchDone:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
for _, fm := range messages {
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(fm.Content.Reader)
if err != nil {
log.Warnf("failed to read message: %v", err)
continue
}
destStore.Append(
c.Folder,
models.SeenFlag,
time.Now(),
buf,
buf.Len(),
func(msg types.WorkerMessage) {
c.CallBack(msg, uids, store)
},
)
}
}()
return nil
}
func (c Copy) CallBack(msg types.WorkerMessage, uids []models.UID, store *lib.MessageStore) {
dest := c.Folder
if len(c.Account) != 0 {
dest = fmt.Sprintf("%s in %s", c.Folder, c.Account)
}
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages copied to %s"
} else {
s = "%d message copied to %s"
}
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
}
+164
View File
@@ -0,0 +1,164 @@
package msg
import (
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Delete struct {
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
}
func init() {
commands.Register(Delete{})
}
func (Delete) Description() string {
return "Delete the selected message(s)."
}
func (Delete) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Delete) Aliases() []string {
return []string{"delete", "delete-message"}
}
func (d *Delete) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
d.MultiFileStrategy = &mfs
}
return nil
}
func (Delete) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (d Delete) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
acct, err := h.account()
if err != nil {
return err
}
sel := store.Selected()
marker := store.Marker()
marker.ClearVisualMark()
// caution, can be nil
next := findNextNonDeleted(uids, store)
store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages deleted"
} else {
s = "%d message deleted"
}
app.PushStatus(fmt.Sprintf(s, len(uids)), 10*time.Second)
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
if isMsgView {
if !config.Ui.NextMessageOnDelete {
app.RemoveTab(h.msgProvider, true)
} else {
// no more messages in the list
if next == nil {
app.RemoveTab(h.msgProvider, true)
acct.Messages().Select(-1)
ui.Invalidate()
return
}
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
store, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
nextMv, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
})
}
} else {
if next == nil {
// We deleted the last message, select the new last message
// instead of the first message
acct.Messages().Select(-1)
}
}
case *types.Error:
marker.Remark()
store.Select(sel.Uid)
app.PushError(msg.Error.Error())
case *types.Unsupported:
marker.Remark()
store.Select(sel.Uid)
// notmuch doesn't support it, we want the user to know
app.PushError(" error, unsupported for this worker")
}
})
return nil
}
func findNextNonDeleted(deleted []models.UID, store *lib.MessageStore) *models.MessageInfo {
var next, previous *models.MessageInfo
stepper := []func(){store.Next, store.Prev}
for _, stepFn := range stepper {
previous = nil
for {
next = store.Selected()
if next != nil && !contains(deleted, next.Uid) {
if _, deleted := store.Deleted[next.Uid]; !deleted {
return next
}
}
if next == nil || previous == next {
// If previous == next, this is the last
// message. Set next to nil either way
next = nil
break
}
stepFn()
previous = next
}
}
if next != nil {
store.Select(next.Uid)
}
return next
}
func contains(uids []models.UID, uid models.UID) bool {
for _, item := range uids {
if item == uid {
return true
}
}
return false
}
+134
View File
@@ -0,0 +1,134 @@
package msg
import (
"errors"
"fmt"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
)
type Envelope struct {
Header bool `opt:"-h" desc:"Show all header fields."`
Format string `opt:"-s" default:"%-20.20s: %s" desc:"Format specifier."`
}
func init() {
commands.Register(Envelope{})
}
func (Envelope) Description() string {
return "Open the message envelope in a dialog popup."
}
func (Envelope) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Envelope) Aliases() []string {
return []string{"envelope"}
}
func (e Envelope) Execute(args []string) error {
provider, ok := app.SelectedTabContent().(app.ProvidesMessages)
if !ok {
return fmt.Errorf("current tab does not implement app.ProvidesMessage interface")
}
acct := provider.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
var list []string
if msg, err := provider.SelectedMessage(); err != nil {
return err
} else {
if msg != nil {
if e.Header {
list = parseHeader(msg, e.Format)
} else {
list = parseEnvelope(msg, e.Format,
acct.UiConfig().TimestampFormat)
}
} else {
return fmt.Errorf("Selected message is empty.")
}
}
app.AddDialog(app.DefaultDialog(
app.NewListBox(
"Message Envelope. Press <Esc> or <Enter> to close. "+
"Start typing to filter.",
list,
app.SelectedAccountUiConfig(),
func(_ string) {
app.CloseDialog()
},
),
))
return nil
}
func parseEnvelope(msg *models.MessageInfo, fmtStr, fmtTime string,
) (result []string) {
if envlp := msg.Envelope; envlp != nil {
addStr := func(key, text string) {
result = append(result, fmt.Sprintf(fmtStr, key, text))
}
addAddr := func(key string, ls []*mail.Address) {
for _, l := range ls {
result = append(result,
fmt.Sprintf(fmtStr, key,
format.AddressForHumans(l)))
}
}
addStr("Date", envlp.Date.Format(fmtTime))
addStr("Subject", envlp.Subject)
addStr("Message-Id", envlp.MessageId)
addAddr("From", envlp.From)
addAddr("To", envlp.To)
addAddr("ReplyTo", envlp.ReplyTo)
addAddr("Cc", envlp.Cc)
addAddr("Bcc", envlp.Bcc)
}
return
}
func parseHeader(msg *models.MessageInfo, fmtStr string) (result []string) {
if h := msg.RFC822Headers; h != nil {
hf := h.Fields()
for hf.Next() {
text, err := hf.Text()
if err != nil {
log.Errorf(err.Error())
text = hf.Value()
}
result = append(result,
headerExpand(fmtStr, hf.Key(), text)...)
}
}
return
}
func headerExpand(fmtStr, key, text string) []string {
var result []string
switch strings.ToLower(key) {
case "to", "from", "bcc", "cc":
for _, item := range strings.Split(text, ",") {
result = append(result, fmt.Sprintf(fmtStr, key,
strings.TrimSpace(item)))
}
default:
result = append(result, fmt.Sprintf(fmtStr, key, text))
}
return result
}
+73
View File
@@ -0,0 +1,73 @@
package msg
import (
"errors"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type Fold struct {
All bool `opt:"-a" desc:"Fold/unfold all threads."`
Toggle bool `opt:"-t" desc:"Toggle between folded/unfolded."`
}
func init() {
commands.Register(Fold{})
}
func (Fold) Description() string {
return "Collapse or expand the thread children of the selected message."
}
func (Fold) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Fold) Aliases() []string {
return []string{"fold", "unfold"}
}
func (f Fold) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
if f.All {
point := store.SelectedUid()
uids := store.Uids()
for _, uid := range uids {
t, err := store.Thread(uid)
if err == nil && t.Parent == nil {
switch args[0] {
case "fold":
err = store.Fold(uid, f.Toggle)
case "unfold":
err = store.Unfold(uid, f.Toggle)
}
}
if err != nil {
return err
}
}
store.Select(point)
ui.Invalidate()
return err
}
msg := store.Selected()
if msg == nil {
return errors.New("No message selected")
}
switch args[0] {
case "fold":
err = store.Fold(msg.Uid, f.Toggle)
case "unfold":
err = store.Unfold(msg.Uid, f.Toggle)
}
ui.Invalidate()
return err
}
+264
View File
@@ -0,0 +1,264 @@
package msg
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"math/rand"
"os"
"path"
"strings"
"sync"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-message/mail"
)
type forward struct {
AttachAll bool `opt:"-A" desc:"Forward the message and all attachments."`
AttachFull bool `opt:"-F" desc:"Forward the full message as an RFC 2822 attachment."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
To []string `opt:"..." required:"false" complete:"CompleteTo" desc:"Recipient from address book."`
}
func init() {
commands.Register(forward{})
}
func (forward) Description() string {
return "Open the composer to forward the selected message to another recipient."
}
func (forward) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (forward) Aliases() []string {
return []string{"forward"}
}
func (*forward) CompleteTemplate(arg string) []string {
return commands.GetTemplates(arg)
}
func (*forward) CompleteTo(arg string) []string {
return commands.GetAddress(arg)
}
func (f forward) Execute(args []string) error {
if f.AttachAll && f.AttachFull {
return errors.New("Options -A and -F are mutually exclusive")
}
editHeaders := (config.Compose.EditHeaders || f.Edit) && !f.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
msg, err := widget.SelectedMessage()
if err != nil {
return err
}
log.Debugf("Forwarding email <%s>", msg.Envelope.MessageId)
h := &mail.Header{}
subject := "Fwd: " + msg.Envelope.Subject
h.SetSubject(subject)
var tolist []*mail.Address
to := strings.Join(f.To, ", ")
if strings.Contains(to, "@") {
tolist, err = mail.ParseAddressList(to)
if err != nil {
return fmt.Errorf("invalid to address(es): %w", err)
}
}
if len(tolist) > 0 {
h.SetAddressList("to", tolist)
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
addTab := func() (*app.Composer, error) {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
f.Template, h, &original, nil)
if err != nil {
app.PushError("Error: " + err.Error())
return nil, err
}
composer.Tab = app.NewTab(composer, subject)
switch {
case f.SkipEditor:
composer.Terminal().Close()
case !h.Has("to"):
composer.FocusEditor("to")
default:
composer.FocusTerminal()
}
return composer, nil
}
mv, isMsgViewer := widget.(*app.MessageViewer)
store := widget.Store()
noStore := store == nil
if noStore && !isMsgViewer {
return errors.New("Cannot perform action. Messages still loading")
}
if f.AttachFull {
tmpDir, err := os.MkdirTemp("", "aerc-tmp-attachment")
if err != nil {
return err
}
tmpFileName := path.Join(tmpDir,
strings.ReplaceAll(fmt.Sprintf("%s.eml", msg.Envelope.Subject), "/", "-"))
var fetchFull func(func(io.Reader))
if isMsgViewer {
fetchFull = mv.MessageView().FetchFull
} else {
fetchFull = func(cb func(io.Reader)) {
store.FetchFull([]models.UID{msg.Uid}, func(fm *types.FullMessage) {
if fm == nil || (fm != nil && fm.Content == nil) {
return
}
cb(fm.Content.Reader)
})
}
}
fetchFull(func(r io.Reader) {
tmpFile, err := os.Create(tmpFileName)
if err != nil {
log.Warnf("failed to create temporary attachment: %v", err)
_, err = addTab()
if err != nil {
log.Warnf("failed to add tab: %v", err)
}
return
}
defer tmpFile.Close()
_, err = io.Copy(tmpFile, r)
if err != nil {
log.Warnf("failed to write to tmpfile: %v", err)
return
}
composer, err := addTab()
if err != nil {
return
}
composer.AddAttachment(tmpFileName)
composer.OnClose(func(c *app.Composer) {
if c.Sent() && store != nil {
store.Forwarded([]models.UID{msg.Uid}, true, nil)
}
os.RemoveAll(tmpDir)
})
})
} else {
if f.Template == "" {
f.Template = config.Templates.Forwards
}
var fetchBodyPart func([]int, func(io.Reader))
if isMsgViewer {
fetchBodyPart = mv.MessageView().FetchBodyPart
} else {
fetchBodyPart = func(part []int, cb func(io.Reader)) {
store.FetchBodyPart(msg.Uid, part, cb)
}
}
if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer {
return fmt.Errorf("message is encrypted. " +
"can only forward from the message viewer")
}
part := getMessagePart(msg, widget)
if part == nil {
part = lib.FindFirstNonMultipart(msg.BodyStructure, nil)
// if it's still nil here, we don't have a multipart msg, that's fine
}
err = addMimeType(msg, part, &original)
if err != nil {
return err
}
fetchBodyPart(part, func(reader io.Reader) {
buf := new(bytes.Buffer)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
buf.WriteString(scanner.Text() + "\n")
}
original.Text = buf.String()
// create composer
composer, err := addTab()
if err != nil {
return
}
composer.OnClose(func(c *app.Composer) {
if c.Sent() && store != nil {
store.Forwarded([]models.UID{msg.Uid}, true, nil)
}
})
// add attachments
if f.AttachAll {
var mu sync.Mutex
parts := lib.FindAllNonMultipart(msg.BodyStructure, nil, nil)
for _, p := range parts {
if lib.EqualParts(p, part) {
continue
}
bs, err := msg.BodyStructure.PartAtIndex(p)
if err != nil {
log.Errorf("cannot get PartAtIndex %v: %v", p, err)
continue
}
fetchBodyPart(p, func(reader io.Reader) {
mime := bs.FullMIMEType()
params := lib.SetUtf8Charset(bs.Params)
name := bs.FileName()
if name == "" {
name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64())
}
mu.Lock()
err := composer.AddPartAttachment(name, mime, params, reader)
mu.Unlock()
if err != nil {
log.Errorf(err.Error())
app.PushError(err.Error())
}
})
}
}
})
}
return nil
}
+180
View File
@@ -0,0 +1,180 @@
package msg
import (
"errors"
"fmt"
"io"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/calendar"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
)
type invite struct {
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(invite{})
}
func (invite) Description() string {
return "Accept or decline a meeting invitation."
}
func (invite) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (invite) Aliases() []string {
return []string{"accept", "accept-tentative", "decline"}
}
func (i invite) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("no account selected")
}
store := acct.Store()
if store == nil {
return errors.New("cannot perform action: messages still loading")
}
msg, err := acct.SelectedMessage()
if err != nil {
return err
}
part := lib.FindCalendartext(msg.BodyStructure, nil)
if part == nil {
return fmt.Errorf("no invitation found (missing text/calendar)")
}
editHeaders := (config.Compose.EditHeaders || i.Edit) && !i.NoEdit
subject := trimLocalizedRe(msg.Envelope.Subject, acct.AccountConfig().LocalizedRe)
switch args[0] {
case "accept":
subject = "Accepted: " + subject
case "accept-tentative":
subject = "Tentatively Accepted: " + subject
case "decline":
subject = "Declined: " + subject
default:
return fmt.Errorf("no participation status defined")
}
from := chooseFromAddr(acct.AccountConfig(), msg)
var to []*mail.Address
if len(msg.Envelope.ReplyTo) != 0 {
to = msg.Envelope.ReplyTo
} else {
to = msg.Envelope.From
}
if !config.Compose.ReplyToSelf {
for i, v := range to {
if v.Address == from.Address {
to = append(to[:i], to[i+1:]...)
break
}
}
if len(to) == 0 {
to = msg.Envelope.To
}
}
recSet := newAddrSet() // used for de-duping
recSet.AddList(to)
h := &mail.Header{}
h.SetAddressList("from", []*mail.Address{from})
h.SetSubject(subject)
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
err = setReferencesHeader(h, msg.RFC822Headers)
if err != nil {
app.PushError(fmt.Sprintf("could not set references: %v", err))
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
handleInvite := func(reader io.Reader) (*calendar.Reply, error) {
cr, err := calendar.CreateReply(reader, from, args[0])
if err != nil {
return nil, err
}
for _, org := range cr.Organizers {
organizer, err := mail.ParseAddress(org)
if err != nil {
continue
}
if !recSet.Contains(organizer) {
to = append(to, organizer)
}
}
h.SetAddressList("to", to)
return cr, nil
}
addTab := func(cr *calendar.Reply) error {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
"", h, &original, cr.PlainText)
if err != nil {
app.PushError("Error: " + err.Error())
return err
}
err = composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText)
if err != nil {
return fmt.Errorf("failed to write invitation: %w", err)
}
if i.SkipEditor {
composer.Terminal().Close()
} else {
composer.FocusTerminal()
}
composer.Tab = app.NewTab(composer, subject)
composer.OnClose(func(c *app.Composer) {
switch {
case c.Sent() && c.Archive() != "":
store.Answered([]models.UID{msg.Uid}, true, nil)
err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
if err != nil {
app.PushStatus("Archive failed", 10*time.Second)
}
case c.Sent():
store.Answered([]models.UID{msg.Uid}, true, nil)
}
})
return nil
}
store.FetchBodyPart(msg.Uid, part, func(reader io.Reader) {
if cr, err := handleInvite(reader); err != nil {
app.PushError(err.Error())
return
} else {
err := addTab(cr)
if err != nil {
log.Warnf("failed to add tab: %v", err)
}
}
})
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package msg
import (
"fmt"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/models"
)
type Mark struct {
All bool `opt:"-a" aliases:"mark,unmark" desc:"Mark all messages in current folder."`
Toggle bool `opt:"-t" aliases:"mark,unmark" desc:"Toggle the marked state."`
Visual bool `opt:"-v" aliases:"mark,unmark" desc:"Enter / leave visual mark mode."`
VisualClear bool `opt:"-V" aliases:"mark,unmark" desc:"Same as -v but does not clear existing selection."`
Thread bool `opt:"-T" aliases:"mark,unmark" desc:"Mark all messages from the selected thread."`
}
func init() {
commands.Register(Mark{})
}
func (Mark) Description() string {
return "Mark, unmark or remark messages."
}
func (Mark) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Mark) Aliases() []string {
return []string{"mark", "unmark", "remark"}
}
func (m Mark) Execute(args []string) error {
h := newHelper()
OnSelectedMessage := func(fn func(models.UID)) error {
if fn == nil {
return fmt.Errorf("no operation selected")
}
selected, err := h.msgProvider.SelectedMessage()
if err != nil {
return err
}
fn(selected.Uid)
return nil
}
store, err := h.store()
if err != nil {
return err
}
marker := store.Marker()
if m.Thread && m.All {
return fmt.Errorf("-a and -T are mutually exclusive")
}
if m.Thread && (m.Visual || m.VisualClear) {
return fmt.Errorf("-v and -T are mutually exclusive")
}
if m.Visual && m.All {
return fmt.Errorf("-a and -v are mutually exclusive")
}
switch args[0] {
case "mark":
var modFunc func(models.UID)
if m.Toggle {
modFunc = marker.ToggleMark
} else {
modFunc = marker.Mark
}
switch {
case m.All:
uids := store.Uids()
for _, uid := range uids {
modFunc(uid)
}
return nil
case m.Visual || m.VisualClear:
marker.ToggleVisualMark(m.VisualClear)
return nil
default:
if m.Thread {
threadPtr, err := store.SelectedThread()
if err != nil {
return err
}
for _, uid := range threadPtr.Root().Uids() {
modFunc(uid)
}
} else {
return OnSelectedMessage(modFunc)
}
return nil
}
case "unmark":
if m.Visual || m.VisualClear {
return fmt.Errorf("visual mode not supported for this command")
}
switch {
case m.All && m.Toggle:
uids := store.Uids()
for _, uid := range uids {
marker.ToggleMark(uid)
}
return nil
case m.All && !m.Toggle:
marker.ClearVisualMark()
return nil
default:
if m.Thread {
threadPtr, err := store.SelectedThread()
if err != nil {
return err
}
for _, uid := range threadPtr.Root().Uids() {
marker.Unmark(uid)
}
} else {
return OnSelectedMessage(marker.Unmark)
}
return nil
}
case "remark":
marker.Remark()
return nil
}
return nil // never reached
}
+70
View File
@@ -0,0 +1,70 @@
package msg
import (
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type ModifyLabels struct {
Labels []string `opt:"..." metavar:"[+-]<label>" complete:"CompleteLabels" desc:"Message label."`
}
func init() {
commands.Register(ModifyLabels{})
}
func (ModifyLabels) Description() string {
return "Modify message labels."
}
func (ModifyLabels) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (ModifyLabels) Aliases() []string {
return []string{"modify-labels", "tag"}
}
func (*ModifyLabels) CompleteLabels(arg string) []string {
return commands.GetLabels(arg)
}
func (m ModifyLabels) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
var add, remove []string
for _, l := range m.Labels {
switch l[0] {
case '+':
add = append(add, l[1:])
case '-':
remove = append(remove, l[1:])
default:
// if no operand is given assume add
add = append(add, l)
}
}
store.ModifyLabels(uids, add, remove, func(
msg types.WorkerMessage,
) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("labels updated", 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
})
return nil
}
+267
View File
@@ -0,0 +1,267 @@
package msg
import (
"bytes"
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/marker"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Move struct {
CreateFolders bool `opt:"-p" desc:"Create missing folders if required."`
Account string `opt:"-a" complete:"CompleteAccount" desc:"Move to specified account."`
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
}
func init() {
commands.Register(Move{})
}
func (Move) Description() string {
return "Move the selected message(s) to the specified folder."
}
func (Move) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Move) Aliases() []string {
return []string{"mv", "move"}
}
func (m *Move) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
m.MultiFileStrategy = &mfs
}
return nil
}
func (*Move) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (m *Move) CompleteFolder(arg string) []string {
var acct *app.AccountView
if len(m.Account) > 0 {
acct, _ = app.Account(m.Account)
} else {
acct = app.SelectedAccount()
}
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, nil)
}
func (Move) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (m Move) Execute(args []string) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
next := findNextNonDeleted(uids, store)
marker := store.Marker()
marker.ClearVisualMark()
if len(m.Account) == 0 {
store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy,
func(msg types.WorkerMessage) {
m.CallBack(msg, acct, uids, next, marker, false)
})
return nil
}
destAcct, err := app.Account(m.Account)
if err != nil {
return err
}
destStore := destAcct.Store()
if destStore == nil {
app.PushError(fmt.Sprintf("No message store in %s", m.Account))
return nil
}
var messages []*types.FullMessage
fetchDone := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
messages = append(messages, fm)
if len(messages) == len(uids) {
fetchDone <- true
}
})
// Since this operation can take some time with some backends
// (e.g. IMAP), provide some feedback to inform the user that
// something is happening
app.PushStatus("Moving messages...", 10*time.Second)
var appended []models.UID
var timeout bool
go func() {
defer log.PanicHandler()
select {
case <-fetchDone:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
AppendLoop:
for _, fm := range messages {
done := make(chan bool, 1)
uid := fm.Content.Uid
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(fm.Content.Reader)
if err != nil {
log.Errorf("could not get reader for uid %d", uid)
break
}
destStore.Append(
m.Folder,
models.SeenFlag,
time.Now(),
buf,
buf.Len(),
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
appended = append(appended, uid)
done <- true
case *types.Error:
log.Errorf("AppendMessage failed: %v", msg.Error)
done <- false
}
},
)
select {
case ok := <-done:
if !ok {
break AppendLoop
}
case <-time.After(30 * time.Second):
log.Warnf("timed-out: appended %d of %d", len(appended), len(messages))
timeout = true
break AppendLoop
}
}
if len(appended) > 0 {
mfs := types.Refuse
store.Delete(appended, &mfs, func(msg types.WorkerMessage) {
m.CallBack(msg, acct, appended, next, marker, timeout)
})
}
}()
return nil
}
func (m Move) CallBack(
msg types.WorkerMessage,
acct *app.AccountView,
uids []models.UID,
next *models.MessageInfo,
marker marker.Marker,
timeout bool,
) {
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages moved to %s"
} else {
s = "%d message moved to %s"
}
dest := m.Folder
if len(m.Account) > 0 {
dest = fmt.Sprintf("%s in %s", m.Folder, m.Account)
}
if timeout {
s = "timed-out: only " + s
app.PushError(fmt.Sprintf(s, len(uids), dest))
} else {
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
}
if store := acct.Store(); store != nil {
handleDone(acct, next, store)
}
case *types.Error:
app.PushError(msg.Error.Error())
marker.Remark()
case *types.Unsupported:
marker.Remark()
app.PushError("error, unsupported for this worker")
}
}
func handleDone(
acct *app.AccountView,
next *models.MessageInfo,
store *lib.MessageStore,
) {
h := newHelper()
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
switch {
case isMsgView && !config.Ui.NextMessageOnDelete:
app.RemoveTab(h.msgProvider, true)
case isMsgView:
if next == nil {
app.RemoveTab(h.msgProvider, true)
acct.Messages().Select(-1)
ui.Invalidate()
return
}
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
store, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
nextMv, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
})
default:
if next == nil {
// We moved the last message, select the new last message
// instead of the first message
acct.Messages().Select(-1)
}
}
}
+302
View File
@@ -0,0 +1,302 @@
package msg
import (
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"regexp"
"sort"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Pipe struct {
Background bool `opt:"-b" desc:"Run the command in the background."`
Silent bool `opt:"-s" desc:"Silently close the terminal tab after the command exits."`
Full bool `opt:"-m" desc:"Pipe the full message."`
Decrypt bool `opt:"-d" desc:"Decrypt the full message before piping."`
Part bool `opt:"-p" desc:"Only pipe the selected message part."`
Command string `opt:"..."`
}
func init() {
commands.Register(Pipe{})
}
func (Pipe) Description() string {
return "Pipe the selected message(s) into the given shell command."
}
func (Pipe) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Pipe) Aliases() []string {
return []string{"pipe"}
}
func (p Pipe) Execute(args []string) error {
return p.Run(nil)
}
func (p Pipe) Run(cb func()) error {
if p.Decrypt {
// Decrypt implies fetching the full message
p.Full = true
}
if p.Full && p.Part {
return errors.New("-m and -p are mutually exclusive")
}
name, _, _ := strings.Cut(p.Command, " ")
provider := app.SelectedTabContent().(app.ProvidesMessage)
if !p.Full && !p.Part {
if _, ok := provider.(*app.MessageViewer); ok {
p.Part = true
} else if _, ok := provider.(*app.AccountView); ok {
p.Full = true
} else {
return errors.New(
"Neither -m nor -p specified and cannot infer default")
}
}
doTerm := func(reader io.Reader, name string) {
cmd := []string{"sh", "-c", p.Command}
term, err := commands.QuickTerm(cmd, reader, p.Silent)
if err != nil {
app.PushError(err.Error())
return
}
if cb != nil {
last := term.OnClose
term.OnClose = func(err error) {
if last != nil {
last(err)
}
cb()
}
}
app.NewTab(term, name)
}
doExec := func(reader io.Reader) {
ecmd := exec.Command("sh", "-c", p.Command)
pipe, err := ecmd.StdinPipe()
if err != nil {
return
}
go func() {
defer log.PanicHandler()
defer pipe.Close()
_, err := io.Copy(pipe, reader)
if err != nil {
log.Errorf("failed to send data to pipe: %v", err)
}
}()
err = ecmd.Run()
if err != nil {
app.PushError(err.Error())
} else {
if ecmd.ProcessState.ExitCode() != 0 {
app.PushError(fmt.Sprintf(
"%s: completed with status %d", name,
ecmd.ProcessState.ExitCode()))
} else {
app.PushStatus(fmt.Sprintf(
"%s: completed with status %d", name,
ecmd.ProcessState.ExitCode()), 10*time.Second)
}
}
if cb != nil {
cb()
}
}
app.PushStatus("Fetching messages ...", 10*time.Second)
if p.Full {
var uids []models.UID
var title string
h := newHelper()
store, err := h.store()
if err != nil {
if mv, ok := provider.(*app.MessageViewer); ok {
mv.MessageView().FetchFull(func(reader io.Reader) {
if p.Background {
doExec(reader)
} else {
doTerm(reader,
fmt.Sprintf("%s <%s",
name, title))
}
})
return nil
}
return err
}
uids, err = h.markedOrSelectedUids()
if err != nil {
return err
}
if len(uids) == 1 {
info := store.Messages[uids[0]]
if info != nil {
envelope := info.Envelope
if envelope != nil {
title = envelope.Subject
}
}
}
if title == "" {
title = fmt.Sprintf("%d messages", len(uids))
}
var messages []*types.FullMessage
var errors []error
done := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
if p.Decrypt {
info := store.Messages[fm.Content.Uid]
if info == nil {
goto addMessage
}
var buf bytes.Buffer
cleartext, err := cryptoutil.Cleartext(
io.TeeReader(fm.Content.Reader, &buf),
info.RFC822Headers.Copy(),
)
if err != nil {
log.Warnf("continue encrypted: %v", err)
fm.Content.Reader = bytes.NewReader(buf.Bytes())
} else {
fm.Content.Reader = bytes.NewReader(cleartext)
}
}
addMessage:
info := store.Messages[fm.Content.Uid]
switch {
case info != nil && info.Envelope != nil:
messages = append(messages, fm)
case info != nil && info.Error != nil:
app.PushError(info.Error.Error())
errors = append(errors, info.Error)
default:
err := fmt.Errorf("%v nil info", fm.Content.Uid)
app.PushError(err.Error())
errors = append(errors, err)
}
if len(messages)+len(errors) == len(uids) {
done <- true
}
})
go func() {
defer log.PanicHandler()
select {
case <-done:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
is_git_patches := false
for _, msg := range messages {
info := store.Messages[msg.Content.Uid]
if info == nil || info.Envelope == nil {
continue
}
if patchSeriesRe.MatchString(info.Envelope.Subject) {
is_git_patches = true
break
}
}
if is_git_patches {
// Sort all messages by increasing Message-Id header.
// This will ensure that patch series are applied in order.
sort.Slice(messages, func(i, j int) bool {
infoi := store.Messages[messages[i].Content.Uid]
infoj := store.Messages[messages[j].Content.Uid]
if infoi == nil || infoi.Envelope == nil ||
infoj == nil || infoj.Envelope == nil {
return false
}
return infoi.Envelope.Subject < infoj.Envelope.Subject
})
}
reader := newMessagesReader(messages, len(messages) > 1)
if p.Background {
doExec(reader)
} else {
doTerm(reader, fmt.Sprintf("%s <%s", name, title))
}
}()
} else if p.Part {
mv, ok := provider.(*app.MessageViewer)
if !ok {
return fmt.Errorf("can only pipe message part from a message view")
}
part := provider.SelectedMessagePart()
if part == nil {
return fmt.Errorf("could not fetch message part")
}
mv.MessageView().FetchBodyPart(part.Index, func(reader io.Reader) {
if p.Background {
doExec(reader)
} else {
name := fmt.Sprintf("%s <%s/[%d]",
name, part.Msg.Envelope.Subject, part.Index)
doTerm(reader, name)
}
})
}
if store := provider.Store(); store != nil {
store.Marker().ClearVisualMark()
}
return nil
}
func newMessagesReader(messages []*types.FullMessage, useMbox bool) io.Reader {
pr, pw := io.Pipe()
go func() {
defer log.PanicHandler()
defer pw.Close()
for _, msg := range messages {
var err error
if useMbox {
err = mboxer.Write(pw, msg.Content.Reader, "", time.Now())
} else {
_, err = io.Copy(pw, msg.Content.Reader)
}
if err != nil {
log.Warnf("failed to write data: %v", err)
}
}
}()
return pr
}
var patchSeriesRe = regexp.MustCompile(
`^.*\[(RFC )?PATCH( [^\]]+)? \d+/\d+] .+$`,
)
+162
View File
@@ -0,0 +1,162 @@
package msg
import (
"fmt"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type FlagMsg struct {
Toggle bool `opt:"-t" desc:"Toggle between set and unset."`
Answered bool `opt:"-a" aliases:"flag,unflag" desc:"Set/unset the answered flag."`
Forwarded bool `opt:"-f" aliases:"flag,unflag" desc:"Set/unset the forwarded flag."`
Flag models.Flags `opt:"-x" aliases:"flag,unflag" action:"ParseFlag" complete:"CompleteFlag" desc:"Flag name."`
FlagName string
}
func init() {
commands.Register(FlagMsg{})
}
func (FlagMsg) Description() string {
return "Set or unset a flag on the marked or selected messages."
}
func (FlagMsg) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (FlagMsg) Aliases() []string {
return []string{"flag", "unflag", "read", "unread"}
}
func (f *FlagMsg) ParseFlag(arg string) error {
switch strings.ToLower(arg) {
case "seen":
f.Flag = models.SeenFlag
f.FlagName = "seen"
case "answered":
f.Flag = models.AnsweredFlag
f.FlagName = "answered"
case "forwarded":
f.Flag = models.ForwardedFlag
f.FlagName = "forwarded"
case "flagged":
f.Flag = models.FlaggedFlag
f.FlagName = "flagged"
case "draft":
f.Flag = models.DraftFlag
f.FlagName = "draft"
default:
return fmt.Errorf("Unknown flag %q", arg)
}
return nil
}
var validFlags = []string{"seen", "answered", "forwarded", "flagged", "draft"}
func (*FlagMsg) CompleteFlag(arg string) []string {
return commands.FilterList(validFlags, arg, nil)
}
// If this was called as 'flag' or 'unflag', without the toggle (-t)
// option, then it will flag the corresponding messages with the given
// flag. If the toggle option was given, it will individually toggle
// the given flag for the corresponding messages.
//
// If this was called as 'read' or 'unread', it has the same effect as
// 'flag' or 'unflag', respectively, but the 'Seen' flag is affected.
func (f FlagMsg) Execute(args []string) error {
// User-readable name for the action being performed
var actionName string
switch args[0] {
case "read", "unread":
f.Flag = models.SeenFlag
f.FlagName = "seen"
case "flag", "unflag":
if f.Answered {
f.Flag = models.AnsweredFlag
f.FlagName = "answered"
}
if f.Forwarded {
f.Flag = models.ForwardedFlag
f.FlagName = "forwarded"
}
if f.Flag == 0 {
f.Flag = models.FlaggedFlag
f.FlagName = "flagged"
}
}
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
// UIDs of messages to enable or disable the flag for.
var toEnable []models.UID
var toDisable []models.UID
if f.Toggle {
// If toggling, split messages into those that need to
// be enabled / disabled.
msgs, err := h.messages()
if err != nil {
return err
}
for _, m := range msgs {
if m.Flags.Has(f.Flag) {
toDisable = append(toDisable, m.Uid)
} else {
toEnable = append(toEnable, m.Uid)
}
}
actionName = "Toggling"
} else {
msgUids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
switch args[0] {
case "read", "flag":
toEnable = msgUids
actionName = "Setting"
default:
toDisable = msgUids
actionName = "Unsetting"
}
}
status := fmt.Sprintf("%s flag %q successful", actionName, f.FlagName)
if len(toEnable) != 0 {
store.Flag(toEnable, f.Flag, true, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus(status, 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
})
}
if len(toDisable) != 0 {
store.Flag(toDisable, f.Flag, false, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus(status, 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
})
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package msg
import (
"fmt"
"io"
"math/rand"
"sync"
"time"
_ "github.com/emersion/go-message/charset"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Recall struct {
Force bool `opt:"-f" desc:"Force recall if not in postpone directory."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(Recall{})
}
func (Recall) Description() string {
return "Open a postponed message for re-editing."
}
func (Recall) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Recall) Aliases() []string {
return []string{"recall"}
}
func (r Recall) Execute(args []string) error {
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := widget.Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
msgInfo, err := widget.SelectedMessage()
if err != nil {
return errors.Wrap(err, "Recall failed")
}
if acct.SelectedDirectory() != acct.AccountConfig().Postpone &&
!msgInfo.Flags.Has(models.DraftFlag) && !r.Force {
return errors.New("Use -f to recall non-draft messages from outside the " +
acct.AccountConfig().Postpone + " directory.")
}
log.Debugf("Recalling message <%s>", msgInfo.Envelope.MessageId)
addTab := func(composer *app.Composer) {
subject := msgInfo.Envelope.Subject
if subject == "" {
subject = "Recalled email"
}
composer.Tab = app.NewTab(composer, subject)
composer.OnClose(func(composer *app.Composer) {
uids := []models.UID{msgInfo.Uid}
deleteMessage := func() {
store.Delete(
uids,
nil,
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("Recalled message deleted", 10*time.Second)
case *types.Error:
app.PushError(msg.Error.Error())
}
},
)
}
if composer.Sent() || composer.Postponed() {
deleteMessage()
}
})
}
lib.NewMessageStoreView(msgInfo, acct.UiConfig().AutoMarkRead,
store, app.CryptoProvider(), app.DecryptKeys,
func(msg lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
var path []int
if len(msg.BodyStructure().Parts) != 0 {
path = lib.FindPlaintext(msg.BodyStructure(), path)
}
msg.FetchBodyPart(path, func(reader io.Reader) {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
"", msgInfo.RFC822Headers, nil, reader)
if err != nil {
app.PushError(err.Error())
return
}
if md := msg.MessageDetails(); md != nil {
if md.IsEncrypted {
composer.SetEncrypt(md.IsEncrypted)
}
if md.IsSigned {
err = composer.SetSign(md.IsSigned)
if err != nil {
log.Warnf("failed to set signed state: %v", err)
}
}
}
// add attachments if present
var mu sync.Mutex
parts := lib.FindAllNonMultipart(msg.BodyStructure(), nil, nil)
for _, p := range parts {
if lib.EqualParts(p, path) {
continue
}
bs, err := msg.BodyStructure().PartAtIndex(p)
if err != nil {
log.Warnf("cannot get PartAtIndex %v: %v", p, err)
continue
}
msg.FetchBodyPart(p, func(reader io.Reader) {
mime := bs.FullMIMEType()
params := lib.SetUtf8Charset(bs.Params)
name, ok := params["name"]
if !ok {
name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64())
}
mu.Lock()
err := composer.AddPartAttachment(name, mime, params, reader)
mu.Unlock()
if err != nil {
log.Errorf(err.Error())
app.PushError(err.Error())
}
})
}
if r.Force {
composer.SetRecalledFrom(acct.SelectedDirectory())
}
if r.SkipEditor {
composer.Terminal().Close()
} else {
// focus the terminal since the header fields are likely already done
composer.FocusTerminal()
}
addTab(composer)
})
})
return nil
}
+354
View File
@@ -0,0 +1,354 @@
package msg
import (
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/account"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/models"
"github.com/danwakefield/fnmatch"
"github.com/emersion/go-message/mail"
)
type reply struct {
All bool `opt:"-a" desc:"Reply to all recipients."`
Close bool `opt:"-c" desc:"Close the view tab when replying."`
From bool `opt:"-f" desc:"Reply to all addresses in From and Reply-To headers."`
Quote bool `opt:"-q" desc:"Alias of -T quoted-reply."`
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
Account string `opt:"-A" complete:"CompleteAccount" desc:"Reply with the specified account."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(reply{})
}
func (reply) Description() string {
return "Open the composer to reply to the selected message."
}
func (reply) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (reply) Aliases() []string {
return []string{"reply"}
}
func (*reply) CompleteTemplate(arg string) []string {
return commands.GetTemplates(arg)
}
func (*reply) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (r reply) Execute(args []string) error {
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
var acct *app.AccountView
var err error
if r.Account == "" {
acct = widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
} else {
acct, err = app.Account(r.Account)
if err != nil {
return err
}
}
conf := acct.AccountConfig()
msg, err := widget.SelectedMessage()
if err != nil {
return err
}
from := chooseFromAddr(conf, msg)
var (
to []*mail.Address
cc []*mail.Address
)
recSet := newAddrSet() // used for de-duping
dedupe := func(addrs []*mail.Address) []*mail.Address {
deduped := make([]*mail.Address, 0, len(addrs))
for _, addr := range addrs {
if recSet.Contains(addr) {
continue
}
recSet.Add(addr)
deduped = append(deduped, addr)
}
return deduped
}
if !config.Compose.ReplyToSelf {
recSet.Add(from)
}
switch {
case len(msg.Envelope.ReplyTo) != 0:
to = dedupe(msg.Envelope.ReplyTo)
case len(msg.Envelope.From) != 0:
to = dedupe(msg.Envelope.From)
default:
to = dedupe(msg.Envelope.Sender)
}
if r.From {
to = append(to, dedupe(msg.Envelope.From)...)
}
if !config.Compose.ReplyToSelf && len(to) == 0 {
recSet = newAddrSet()
to = dedupe(msg.Envelope.To)
}
if r.All {
// order matters, due to the deduping
// in order of importance, first parse the To, then the Cc header
// we add our from address, so that we don't self address ourselves
recSet.Add(from)
to = append(to, dedupe(msg.Envelope.To)...)
cc = append(cc, dedupe(msg.Envelope.Cc)...)
cc = append(cc, dedupe(msg.Envelope.Sender)...)
}
subject := "Re: " + trimLocalizedRe(msg.Envelope.Subject, conf.LocalizedRe)
h := &mail.Header{}
h.SetAddressList("to", to)
h.SetAddressList("cc", cc)
h.SetAddressList("from", []*mail.Address{from})
h.SetSubject(subject)
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
err = setReferencesHeader(h, msg.RFC822Headers)
if err != nil {
app.PushError(fmt.Sprintf("could not set references: %v", err))
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
mv, isMsgViewer := app.SelectedTabContent().(*app.MessageViewer)
store := widget.Store()
noStore := store == nil
switch {
case noStore && isMsgViewer:
app.PushWarning("No message store found: answered flag cannot be set")
case noStore:
return errors.New("Cannot perform action. Messages still loading")
default:
original.Folder = store.Name
}
addTab := func() error {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
r.Template, h, &original, nil)
if err != nil {
app.PushError("Error: " + err.Error())
return err
}
if mv != nil && r.Close {
app.RemoveTab(mv, true)
}
if r.SkipEditor {
composer.Terminal().Close()
} else if args[0] == "reply" {
composer.FocusTerminal()
}
composer.Tab = app.NewTab(composer, subject)
composer.OnClose(func(c *app.Composer) {
switch {
case c.Sent() && c.Archive() != "" && !noStore:
store.Answered([]models.UID{msg.Uid}, true, nil)
err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
if err != nil {
app.PushStatus("Archive failed", 10*time.Second)
}
case c.Sent() && !noStore:
store.Answered([]models.UID{msg.Uid}, true, nil)
case mv != nil && r.Close:
view := account.ViewMessage{Peek: true}
//nolint:errcheck // who cares?
view.Execute([]string{"view", "-p"})
}
})
return nil
}
if r.Quote && r.Template == "" {
r.Template = config.Templates.QuotedReply
}
if r.Template != "" {
var fetchBodyPart func([]int, func(io.Reader))
if isMsgViewer {
fetchBodyPart = mv.MessageView().FetchBodyPart
} else {
fetchBodyPart = func(part []int, cb func(io.Reader)) {
store.FetchBodyPart(msg.Uid, part, cb)
}
}
if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer {
return fmt.Errorf("message is encrypted. " +
"can only include reply from the message viewer")
}
part := getMessagePart(msg, widget)
if part == nil {
// mkey... let's get the first thing that isn't a container
// if that's still nil it's either not a multipart msg (ok) or
// broken (containers only)
part = lib.FindFirstNonMultipart(msg.BodyStructure, nil)
}
err = addMimeType(msg, part, &original)
if err != nil {
return err
}
fetchBodyPart(part, func(reader io.Reader) {
data, err := io.ReadAll(reader)
if err != nil {
log.Warnf("failed to read bodypart: %v", err)
}
original.Text = string(data)
err = addTab()
if err != nil {
log.Warnf("failed to add tab: %v", err)
}
})
return nil
} else {
r.Template = config.Templates.NewMessage
return addTab()
}
}
func chooseFromAddr(conf *config.AccountConfig, msg *models.MessageInfo) *mail.Address {
if len(conf.Aliases) == 0 {
return conf.From
}
rec := newAddrSet()
rec.AddList(msg.Envelope.From)
rec.AddList(msg.Envelope.To)
rec.AddList(msg.Envelope.Cc)
// test the from first, it has priority over any present alias
if rec.Contains(conf.From) {
// do nothing
} else {
for _, a := range conf.Aliases {
if match := rec.FindMatch(a); match != "" {
return &mail.Address{Name: a.Name, Address: match}
}
}
}
return conf.From
}
type addrSet map[string]struct{}
func newAddrSet() addrSet {
s := make(map[string]struct{})
return addrSet(s)
}
func (s addrSet) Add(a *mail.Address) {
s[a.Address] = struct{}{}
}
func (s addrSet) AddList(al []*mail.Address) {
for _, a := range al {
s[a.Address] = struct{}{}
}
}
func (s addrSet) Contains(a *mail.Address) bool {
_, ok := s[a.Address]
return ok
}
func (s addrSet) FindMatch(a *mail.Address) string {
for addr := range s {
if fnmatch.Match(a.Address, addr, 0) {
return addr
}
}
return ""
}
// setReferencesHeader adds the references header to target based on parent
// according to RFC2822
func setReferencesHeader(target, parent *mail.Header) error {
refs := parse.MsgIDList(parent, "references")
if len(refs) == 0 {
// according to the RFC we need to fall back to in-reply-to only if
// References is not set
refs = parse.MsgIDList(parent, "in-reply-to")
}
msgID, err := parent.MessageID()
if err != nil {
return err
}
refs = append(refs, msgID)
target.SetMsgIDList("references", refs)
return nil
}
// addMimeType adds the proper mime type of the part to the originalMail struct
func addMimeType(msg *models.MessageInfo, part []int,
orig *models.OriginalMail,
) error {
// caution, :forward uses the code as well, keep that in mind when modifying
bs, err := msg.BodyStructure.PartAtIndex(part)
if err != nil {
return err
}
orig.MIMEType = bs.FullMIMEType()
return nil
}
// trimLocalizedRe removes known localizations of Re: commonly used by Outlook.
func trimLocalizedRe(subject string, localizedRe *regexp.Regexp) string {
return strings.TrimPrefix(subject, localizedRe.FindString(subject))
}
+35
View File
@@ -0,0 +1,35 @@
package msg
import (
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type ToggleThreadContext struct{}
func init() {
commands.Register(ToggleThreadContext{})
}
func (ToggleThreadContext) Description() string {
return "Show/hide message thread context."
}
func (ToggleThreadContext) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (ToggleThreadContext) Aliases() []string {
return []string{"toggle-thread-context"}
}
func (ToggleThreadContext) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
store.ToggleThreadContext()
ui.Invalidate()
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package msg
import (
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type ToggleThreads struct{}
func init() {
commands.Register(ToggleThreads{})
}
func (ToggleThreads) Description() string {
return "Toggle between message threading and the normal message list."
}
func (ToggleThreads) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (ToggleThreads) Aliases() []string {
return []string{"toggle-threads"}
}
func (ToggleThreads) Execute(args []string) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
store.SetThreadedView(!store.ThreadedView())
acct.SetStatus(state.Threading(store.ThreadedView()))
ui.Invalidate()
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package msg
import (
"bufio"
"errors"
"fmt"
"net/url"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"github.com/emersion/go-message/mail"
)
// Unsubscribe helps people unsubscribe from mailing lists by way of the
// List-Unsubscribe header.
type Unsubscribe struct {
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(Unsubscribe{})
}
func (Unsubscribe) Description() string {
return "Attempt to automatically unsubscribe from mailing lists."
}
func (Unsubscribe) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
// Aliases returns a list of aliases for the :unsubscribe command
func (Unsubscribe) Aliases() []string {
return []string{"unsubscribe"}
}
// Execute runs the Unsubscribe command
func (u Unsubscribe) Execute(args []string) error {
editHeaders := (config.Compose.EditHeaders || u.Edit) && !u.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
msg, err := widget.SelectedMessage()
if err != nil {
return err
}
headers := msg.RFC822Headers
if !headers.Has("list-unsubscribe") {
return errors.New("No List-Unsubscribe header found")
}
text, err := headers.Text("list-unsubscribe")
if err != nil {
return err
}
methods := parseUnsubscribeMethods(text)
if len(methods) == 0 {
return fmt.Errorf("no methods found to unsubscribe")
}
log.Debugf("unsubscribe: found %d methods", len(methods))
unsubscribe := func(method *url.URL) {
log.Debugf("unsubscribe: trying to unsubscribe using %s", method.Scheme)
var err error
switch strings.ToLower(method.Scheme) {
case "mailto":
err = unsubscribeMailto(method, editHeaders, u.SkipEditor)
case "http", "https":
err = unsubscribeHTTP(method)
default:
err = fmt.Errorf("unsubscribe: skipping unrecognized scheme: %s", method.Scheme)
}
if err != nil {
app.PushError(err.Error())
}
}
var title string = "Select method to unsubscribe"
if msg != nil && msg.Envelope != nil && len(msg.Envelope.From) > 0 {
title = fmt.Sprintf("%s from %s", title, msg.Envelope.From[0])
}
options := make([]string, len(methods))
for i, method := range methods {
options[i] = method.Scheme
}
dialog := app.NewSelectorDialog(
title,
"Press <Enter> to confirm or <ESC> to cancel",
options, 0, app.SelectedAccountUiConfig(),
func(option string, err error) {
app.CloseDialog()
if err != nil {
if errors.Is(err, app.ErrNoOptionSelected) {
app.PushStatus("Unsubscribe: "+err.Error(),
5*time.Second)
} else {
app.PushError("Unsubscribe: " + err.Error())
}
return
}
for _, m := range methods {
if m.Scheme == option {
unsubscribe(m)
return
}
}
app.PushError("Unsubscribe: selected method not found")
},
)
app.AddDialog(dialog)
return nil
}
// parseUnsubscribeMethods reads the list-unsubscribe header and parses it as a
// list of angle-bracket <> deliminated URLs. See RFC 2369.
func parseUnsubscribeMethods(header string) (methods []*url.URL) {
r := bufio.NewReader(strings.NewReader(header))
for {
// discard until <
_, err := r.ReadSlice('<')
if err != nil {
return
}
// read until <
m, err := r.ReadSlice('>')
if err != nil {
return
}
m = m[:len(m)-1]
if u, err := url.Parse(string(m)); err == nil {
methods = append(methods, u)
}
}
}
func unsubscribeMailto(u *url.URL, editHeaders, skipEditor bool) error {
widget := app.SelectedTabContent().(app.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
h := &mail.Header{}
h.SetSubject(u.Query().Get("subject"))
if to, err := mail.ParseAddressList(u.Opaque); err == nil {
h.SetAddressList("to", to)
}
composer, err := app.NewComposer(
acct,
acct.AccountConfig(),
acct.Worker(),
editHeaders,
"",
h,
nil,
strings.NewReader(u.Query().Get("body")),
)
if err != nil {
return err
}
composer.Tab = app.NewTab(composer, "unsubscribe")
if skipEditor {
composer.Terminal().Close()
} else {
composer.FocusTerminal()
}
return nil
}
func unsubscribeHTTP(u *url.URL) error {
confirm := app.NewSelectorDialog(
"Do you want to open this link?",
u.String(),
[]string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(),
func(option string, _ error) {
app.CloseDialog()
switch option {
case "Yes":
go func() {
defer log.PanicHandler()
mime := fmt.Sprintf("x-scheme-handler/%s", u.Scheme)
if err := lib.XDGOpenMime(u.String(), mime, ""); err != nil {
app.PushError("Unsubscribe:" + err.Error())
}
}()
default:
app.PushError("Unsubscribe: link will not be opened")
}
},
)
app.AddDialog(confirm)
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package msg
import (
"testing"
)
func TestParseUnsubscribe(t *testing.T) {
type tc struct {
hdr string
expected []string
}
cases := []*tc{
{"", []string{}},
{"invalid", []string{}},
{"<https://example.com>, <http://example.com>", []string{
"https://example.com", "http://example.com",
}},
{"<https://example.com> is a URL", []string{
"https://example.com",
}},
{
"<mailto:user@host?subject=unsubscribe>, <https://example.com>",
[]string{
"mailto:user@host?subject=unsubscribe", "https://example.com",
},
},
{"<>, <https://example> ", []string{
"", "https://example",
}},
}
for _, c := range cases {
result := parseUnsubscribeMethods(c.hdr)
if len(result) != len(c.expected) {
t.Errorf("expected %d methods but got %d", len(c.expected), len(result))
continue
}
for idx := 0; idx < len(result); idx++ {
if result[idx].String() != c.expected[idx] {
t.Errorf("expected %v but got %v", c.expected[idx], result[idx])
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package msg
import (
"errors"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/models"
)
type helper struct {
msgProvider app.ProvidesMessages
statusInfo func(string)
}
func newHelper() *helper {
msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages)
if !ok {
msgProvider = app.SelectedAccount()
}
return &helper{
msgProvider: msgProvider,
statusInfo: func(s string) {
app.PushStatus(s, 10*time.Second)
},
}
}
func (h *helper) markedOrSelectedUids() ([]models.UID, error) {
return commands.MarkedOrSelected(h.msgProvider)
}
func (h *helper) store() (*lib.MessageStore, error) {
store := h.msgProvider.Store()
if store == nil {
return nil, errors.New("Cannot perform action. Messages still loading")
}
return store, nil
}
func (h *helper) account() (*app.AccountView, error) {
acct := h.msgProvider.SelectedAccount()
if acct == nil {
return nil, errors.New("No account selected")
}
return acct, nil
}
func (h *helper) messages() ([]*models.MessageInfo, error) {
uid, err := commands.MarkedOrSelected(h.msgProvider)
if err != nil {
return nil, err
}
store, err := h.store()
if err != nil {
return nil, err
}
return commands.MsgInfoFromUids(store, uid, h.statusInfo)
}
func getMessagePart(msg *models.MessageInfo, provider app.ProvidesMessage) []int {
p := provider.SelectedMessagePart()
if p != nil {
return p.Index
}
for _, mime := range config.Viewer.Alternatives {
part := lib.FindMIMEPart(mime, msg.BodyStructure, nil)
if part != nil {
return part
}
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package msgview
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type NextPrevPart struct {
Offset int `opt:"n" default:"1"`
}
func init() {
commands.Register(NextPrevPart{})
}
func (NextPrevPart) Description() string {
return "Cycle between message parts being shown."
}
func (NextPrevPart) Context() commands.CommandContext {
return commands.MESSAGE_VIEWER
}
func (NextPrevPart) Aliases() []string {
return []string{"next-part", "prev-part"}
}
func (np NextPrevPart) Execute(args []string) error {
mv, _ := app.SelectedTabContent().(*app.MessageViewer)
for n := 0; n < np.Offset; n++ {
if args[0] == "prev-part" {
mv.PreviousPart()
} else {
mv.NextPart()
}
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package msgview
import (
"fmt"
"net/url"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
)
type OpenLink struct {
Url *url.URL `opt:"url" action:"ParseUrl" complete:"CompleteUrl"`
Cmd string `opt:"..." required:"false"`
}
func init() {
commands.Register(OpenLink{})
}
func (OpenLink) Description() string {
return "Open the specified URL with an external program."
}
func (OpenLink) Context() commands.CommandContext {
return commands.MESSAGE_VIEWER
}
func (OpenLink) Aliases() []string {
return []string{"open-link"}
}
func (*OpenLink) CompleteUrl(arg string) []string {
mv := app.SelectedTabContent().(*app.MessageViewer)
if mv != nil {
if p := mv.SelectedMessagePart(); p != nil {
return commands.FilterList(p.Links, arg, nil)
}
}
return nil
}
func (o *OpenLink) ParseUrl(arg string) error {
u, err := url.Parse(arg)
if err != nil {
return err
}
o.Url = u
return nil
}
func (o OpenLink) Execute(args []string) error {
mime := fmt.Sprintf("x-scheme-handler/%s", o.Url.Scheme)
go func() {
defer log.PanicHandler()
if err := lib.XDGOpenMime(o.Url.String(), mime, o.Cmd); err != nil {
app.PushError("open-link: " + err.Error())
}
}()
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package msgview
import (
"errors"
"io"
"mime"
"os"
"path"
"path/filepath"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
)
type Open struct {
Delete bool `opt:"-d" desc:"Delete temp file after the opener exits."`
Cmd string `opt:"..." required:"false"`
}
func init() {
commands.Register(Open{})
}
func (Open) Description() string {
return "Save the current message part to a temporary file, then open it."
}
func (Open) Context() commands.CommandContext {
return commands.MESSAGE_VIEWER
}
func (Open) Aliases() []string {
return []string{"open"}
}
func (o Open) Execute(args []string) error {
mv := app.SelectedTabContent().(*app.MessageViewer)
if mv == nil {
return errors.New("open only supported selected message parts")
}
p := mv.SelectedMessagePart()
mv.MessageView().FetchBodyPart(p.Index, func(reader io.Reader) {
mimeType := ""
part, err := mv.MessageView().BodyStructure().PartAtIndex(p.Index)
if err != nil {
app.PushError(err.Error())
return
}
mimeType = part.FullMIMEType()
tmpDir, err := os.MkdirTemp(os.TempDir(), "aerc-*")
if err != nil {
app.PushError(err.Error())
return
}
filename := path.Base(part.FileName())
var tmpFile *os.File
if filename == "." {
extension := ""
if exts, _ := mime.ExtensionsByType(mimeType); len(exts) > 0 {
extension = exts[0]
}
tmpFile, err = os.CreateTemp(tmpDir, "aerc-*"+extension)
} else {
tmpFile, err = os.Create(filepath.Join(tmpDir, filename))
}
if err != nil {
app.PushError(err.Error())
return
}
_, err = io.Copy(tmpFile, reader)
tmpFile.Close()
if err != nil {
app.PushError(err.Error())
return
}
go func() {
defer log.PanicHandler()
if o.Delete {
defer os.RemoveAll(tmpDir)
}
err = lib.XDGOpenMime(tmpFile.Name(), mimeType, o.Cmd)
if err != nil {
app.PushError("open: " + err.Error())
}
}()
})
return nil
}
+210
View File
@@ -0,0 +1,210 @@
package msgview
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"git.sr.ht/~rjarry/aerc/models"
)
type Save struct {
Force bool `opt:"-f" desc:"Overwrite destination path."`
CreateDirs bool `opt:"-p" desc:"Create missing directories."`
Attachments bool `opt:"-a" desc:"Save all attachments parts."`
AllAttachments bool `opt:"-A" desc:"Save all named parts."`
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Target file path."`
}
func init() {
commands.Register(Save{})
}
func (Save) Description() string {
return "Save the current message part to the given path."
}
func (Save) Context() commands.CommandContext {
return commands.MESSAGE_VIEWER
}
func (Save) Aliases() []string {
return []string{"save"}
}
func (*Save) CompletePath(arg string) []string {
defaultPath := config.General.DefaultSavePath
if defaultPath != "" && !isAbsPath(arg) {
arg = filepath.Join(defaultPath, arg)
}
return commands.CompletePath(arg, false)
}
func (s Save) Execute(args []string) error {
// we either need a path or a defaultPath
if s.Path == "" && config.General.DefaultSavePath == "" {
return errors.New("No default save path in config")
}
// Absolute paths are taken as is so that the user can override the default
// if they want to
if !isAbsPath(s.Path) {
s.Path = filepath.Join(config.General.DefaultSavePath, s.Path)
}
s.Path = xdg.ExpandHome(s.Path)
mv, ok := app.SelectedTabContent().(*app.MessageViewer)
if !ok {
return fmt.Errorf("SelectedTabContent is not a MessageViewer")
}
if s.Attachments || s.AllAttachments {
parts := mv.AttachmentParts(s.AllAttachments)
if len(parts) == 0 {
return fmt.Errorf("This message has no attachments")
}
names := make(map[string]struct{})
for _, pi := range parts {
if err := s.savePart(pi, mv, names); err != nil {
return err
}
}
return nil
}
pi := mv.SelectedMessagePart()
return s.savePart(pi, mv, make(map[string]struct{}))
}
func (s *Save) savePart(
pi *app.PartInfo,
mv *app.MessageViewer,
names map[string]struct{},
) error {
path := s.Path
if s.Attachments || s.AllAttachments || isDirExists(path) {
filename := generateFilename(pi.Part)
path = filepath.Join(path, filename)
}
dir := filepath.Dir(path)
if s.CreateDirs && dir != "" {
err := os.MkdirAll(dir, 0o755)
if err != nil {
return err
}
}
path = getCollisionlessFilename(path, names)
names[path] = struct{}{}
if pathExists(path) && !s.Force {
return fmt.Errorf("%q already exists and -f not given", path)
}
ch := make(chan error, 1)
mv.MessageView().FetchBodyPart(pi.Index, func(reader io.Reader) {
f, err := os.Create(path)
if err != nil {
ch <- err
return
}
defer f.Close()
_, err = io.Copy(f, reader)
if err != nil {
ch <- err
return
}
ch <- nil
})
// we need to wait for the callback prior to displaying a result
go func() {
defer log.PanicHandler()
err := <-ch
if err != nil {
app.PushError(fmt.Sprintf("Save failed: %v", err))
return
}
app.PushStatus("Saved to "+path, 10*time.Second)
}()
return nil
}
func getCollisionlessFilename(path string, existing map[string]struct{}) string {
ext := filepath.Ext(path)
name := strings.TrimSuffix(path, ext)
_, exists := existing[path]
counter := 1
for exists {
path = fmt.Sprintf("%s_%d%s", name, counter, ext)
counter++
_, exists = existing[path]
}
return path
}
// isDir returns true if path is a directory and exists
func isDirExists(path string) bool {
pathinfo, err := os.Stat(path)
if err != nil {
return false // we don't really care
}
if pathinfo.IsDir() {
return true
}
return false
}
// pathExists returns true if path exists
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// isAbsPath returns true if path given is anchored to / or . or ~
func isAbsPath(path string) bool {
if len(path) == 0 {
return false
}
switch path[0] {
case '/':
return true
case '.':
return true
case '~':
return true
default:
return false
}
}
// generateFilename tries to get the filename from the given part.
// if that fails it will fallback to a generated one based on the date
func generateFilename(part *models.BodyStructure) string {
filename := part.FileName()
// Some MUAs send attachments with names like /some/stupid/idea/happy.jpeg
// Assuming non hostile intent it does make sense to use just the last
// portion of the pathname as the filename for saving it.
filename = filename[strings.LastIndex(filename, "/")+1:]
switch filename {
case "", ".", "..":
timestamp := time.Now().Format("2006-01-02-150405")
filename = fmt.Sprintf("aerc_%v", timestamp)
default:
// already have a valid name
}
return filename
}
+24
View File
@@ -0,0 +1,24 @@
package msgview
import "testing"
func TestGetCollisionlessFilename(t *testing.T) {
tests := []struct {
originalFilename string
expectedNewName string
existingFiles map[string]struct{}
}{
{"test", "test", map[string]struct{}{}},
{"test", "test", map[string]struct{}{"other-file": {}}},
{"test.txt", "test.txt", map[string]struct{}{"test.log": {}}},
{"test.txt", "test_1.txt", map[string]struct{}{"test.txt": {}}},
{"test.txt", "test_2.txt", map[string]struct{}{"test.txt": {}, "test_1.txt": {}}},
{"test.txt", "test_1.txt", map[string]struct{}{"test.txt": {}, "test_2.txt": {}}},
}
for _, tt := range tests {
actual := getCollisionlessFilename(tt.originalFilename, tt.existingFiles)
if actual != tt.expectedNewName {
t.Errorf("expected %s, actual %s", tt.expectedNewName, actual)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package msgview
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
)
type ToggleHeaders struct{}
func init() {
commands.Register(ToggleHeaders{})
}
func (ToggleHeaders) Description() string {
return "Toggle the visibility of message headers."
}
func (ToggleHeaders) Context() commands.CommandContext {
return commands.MESSAGE_VIEWER
}
func (ToggleHeaders) Aliases() []string {
return []string{"toggle-headers"}
}
func (ToggleHeaders) Execute(args []string) error {
mv, _ := app.SelectedTabContent().(*app.MessageViewer)
mv.ToggleHeaders()
return nil
}
@@ -0,0 +1,34 @@
package msgview
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/state"
)
type ToggleKeyPassthrough struct{}
func init() {
commands.Register(ToggleKeyPassthrough{})
}
func (ToggleKeyPassthrough) Description() string {
return "Enter or exit the passthrough key bindings context."
}
func (ToggleKeyPassthrough) Context() commands.CommandContext {
return commands.MESSAGE_VIEWER
}
func (ToggleKeyPassthrough) Aliases() []string {
return []string{"toggle-key-passthrough"}
}
func (ToggleKeyPassthrough) Execute(args []string) error {
mv, _ := app.SelectedTabContent().(*app.MessageViewer)
keyPassthroughEnabled := mv.ToggleKeyPassthrough()
if acct := mv.SelectedAccount(); acct != nil {
acct.SetStatus(state.Passthrough(keyPassthroughEnabled))
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
package commands
import (
"git.sr.ht/~rjarry/aerc/app"
)
type NewAccount struct {
Temp bool `opt:"-t" desc:"Create a temporary account."`
}
func init() {
Register(NewAccount{})
}
func (NewAccount) Description() string {
return "Start the new account wizard."
}
func (NewAccount) Context() CommandContext {
return GLOBAL
}
func (NewAccount) Aliases() []string {
return []string{"new-account"}
}
func (n NewAccount) Execute(args []string) error {
wizard := app.NewAccountWizard()
wizard.ConfigureTemporaryAccount(n.Temp)
wizard.Focus(true)
app.NewTab(wizard, "New account")
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package commands
import (
"git.sr.ht/~rjarry/aerc/app"
)
type NextPrevTab struct {
Offset int `opt:"n" default:"1"`
}
func init() {
Register(NextPrevTab{})
}
func (NextPrevTab) Description() string {
return "Cycle to the previous or next tab."
}
func (NextPrevTab) Context() CommandContext {
return GLOBAL
}
func (NextPrevTab) Aliases() []string {
return []string{"next-tab", "prev-tab"}
}
func (np NextPrevTab) Execute(args []string) error {
if np.Offset <= 0 {
return nil
}
offset := np.Offset
if args[0] == "prev-tab" {
offset *= -1
}
app.SelectTabAtOffset(offset)
app.UpdateStatus()
return nil
}
+268
View File
@@ -0,0 +1,268 @@
package patch
import (
"fmt"
"sort"
"strings"
"unicode"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/msg"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
type Apply struct {
Cmd string `opt:"-c" desc:"Apply patches with provided command."`
Worktree string `opt:"-w" desc:"Create linked worktree on this <commit-ish>."`
Tag string `opt:"tag" required:"true" complete:"CompleteTag" desc:"Identify patches with tag."`
}
func init() {
register(Apply{})
}
func (Apply) Description() string {
return "Apply the selected message(s) to the current project."
}
func (Apply) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Apply) Aliases() []string {
return []string{"apply"}
}
func (*Apply) CompleteTag(arg string) []string {
patches, err := pama.New().CurrentPatches()
if err != nil {
log.Errorf("failed to current patches for completion: %v", err)
patches = nil
}
acct := app.SelectedAccount()
if acct == nil {
return nil
}
uids, err := acct.MarkedMessages()
if err != nil {
return nil
}
if len(uids) == 0 {
msg, err := acct.SelectedMessage()
if err == nil {
uids = append(uids, msg.Uid)
}
}
store := acct.Store()
if store == nil {
return nil
}
var subjects []string
for _, uid := range uids {
if msg, ok := store.Messages[uid]; !ok || msg == nil || msg.Envelope == nil {
continue
} else {
subjects = append(subjects, msg.Envelope.Subject)
}
}
return proposePatchName(patches, subjects)
}
func (a Apply) Execute(args []string) error {
patch := a.Tag
worktree := a.Worktree
applyCmd := a.Cmd
m := pama.New()
p, err := m.CurrentProject()
if err != nil {
return err
}
log.Tracef("Current project: %v", p)
if worktree != "" {
p, err = m.CreateWorktree(p, worktree, patch)
if err != nil {
return err
}
err = m.SwitchProject(p.Name)
if err != nil {
log.Warnf("could not switch to worktree project: %v", err)
}
}
if models.Commits(p.Commits).HasTag(patch) {
return fmt.Errorf("Patch name '%s' already exists.", patch)
}
if !m.Clean(p) {
return fmt.Errorf("Aborting... There are unstaged changes in " +
"your repository.")
}
commit, err := m.Head(p)
if err != nil {
return err
}
log.Tracef("HEAD commit before: %s", commit)
if applyCmd != "" {
rootFmt := "%r"
if strings.Contains(applyCmd, rootFmt) {
applyCmd = strings.ReplaceAll(applyCmd, rootFmt, p.Root)
}
log.Infof("use custom apply command: %s", applyCmd)
} else {
applyCmd, err = m.ApplyCmd(p)
if err != nil {
return err
}
}
msgData := collectMessageData()
// apply patches with the pipe cmd
pipe := msg.Pipe{
Background: false,
Full: true,
Part: false,
Command: applyCmd,
}
return pipe.Run(func() {
p, err = m.ApplyUpdate(p, patch, commit, msgData)
if err != nil {
log.Errorf("Failed to save patch data: %v", err)
}
})
}
// collectMessageData returns a map where the key is the message id and the
// value the subject of the marked messages
func collectMessageData() map[string]string {
acct := app.SelectedAccount()
if acct == nil {
return nil
}
uids, err := commands.MarkedOrSelected(acct)
if err != nil {
log.Errorf("error occurred: %v", err)
return nil
}
store := acct.Store()
if store == nil {
return nil
}
kv := make(map[string]string)
for _, uid := range uids {
msginfo, ok := store.Messages[uid]
if !ok || msginfo == nil {
continue
}
id, err := msginfo.MsgId()
if err != nil {
continue
}
if msginfo.Envelope == nil {
continue
}
kv[id] = msginfo.Envelope.Subject
}
return kv
}
func proposePatchName(patches, subjects []string) []string {
parse := func(s string) (string, string, bool) {
var tag strings.Builder
var version string
var i, j int
i = strings.Index(s, "[")
if i < 0 {
goto noPatch
}
s = s[i+1:]
j = strings.Index(s, "]")
if j < 0 {
goto noPatch
}
for _, elem := range strings.Fields(s[:j]) {
vers := strings.ToLower(elem)
if !strings.HasPrefix(vers, "v") {
continue
}
isVersion := true
for _, r := range vers[1:] {
if !unicode.IsDigit(r) {
isVersion = false
break
}
}
if isVersion {
version = vers
break
}
}
s = strings.TrimSpace(s[j+1:])
for _, r := range s {
if unicode.IsSpace(r) || r == ':' {
break
}
_, err := tag.WriteRune(r)
if err != nil {
continue
}
}
return tag.String(), version, true
noPatch:
return "", "", false
}
summary := make(map[string]struct{})
var results []string
for _, s := range subjects {
tag, version, isPatch := parse(s)
if tag == "" || !isPatch {
continue
}
if version == "" {
version = "v1"
}
result := fmt.Sprintf("%s_%s", tag, version)
result = strings.ReplaceAll(result, " ", "")
collision := false
for _, name := range patches {
if name == result {
collision = true
}
}
if collision {
continue
}
_, ok := summary[result]
if ok {
continue
}
results = append(results, result)
summary[result] = struct{}{}
}
sort.Strings(results)
return results
}
+53
View File
@@ -0,0 +1,53 @@
package patch
import (
"reflect"
"testing"
)
func TestPatchApply_ProposeName(t *testing.T) {
tests := []struct {
name string
exist []string
subjects []string
want []string
}{
{
name: "base case",
exist: nil,
subjects: []string{
"[PATCH aerc v3 3/3] notmuch: remove unused code",
"[PATCH aerc v3 2/3] notmuch: replace notmuch library with internal bindings",
"[PATCH aerc v3 1/3] notmuch: add notmuch bindings",
},
want: []string{"notmuch_v3"},
},
{
name: "distorted case",
exist: nil,
subjects: []string{
"[PATCH vaerc v3 3/3] notmuch: remove unused code",
"[PATCH aerc 3v 2/3] notmuch: replace notmuch library with internal bindings",
},
want: []string{"notmuch_v1", "notmuch_v3"},
},
{
name: "invalid patches",
exist: nil,
subjects: []string{
"notmuch: remove unused code",
": replace notmuch library with internal bindings",
},
want: nil,
},
}
for _, test := range tests {
got := proposePatchName(test.exist, test.subjects)
if !reflect.DeepEqual(got, test.want) {
t.Errorf("test '%s' failed to propose the correct "+
"name: got '%v', but want '%v'", test.name,
got, test.want)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package patch
import (
"fmt"
"os"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
type Cd struct{}
func init() {
register(Cd{})
}
func (Cd) Description() string {
return "Change aerc's working directory to the current project."
}
func (Cd) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Cd) Aliases() []string {
return []string{"cd"}
}
func (Cd) Execute(args []string) error {
p, err := pama.New().CurrentProject()
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
if cwd == p.Root {
app.PushStatus("Already here.", 10*time.Second)
return nil
}
err = os.Chdir(p.Root)
if err != nil {
return err
}
app.PushStatus(fmt.Sprintf("Changed to %s.", p.Root),
10*time.Second)
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package patch
import (
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
type Drop struct {
Tag string `opt:"tag" complete:"CompleteTag" desc:"Repository patch tag."`
}
func init() {
register(Drop{})
}
func (Drop) Description() string {
return "Drop a patch from the repository."
}
func (Drop) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Drop) Aliases() []string {
return []string{"drop"}
}
func (*Drop) CompleteTag(arg string) []string {
patches, err := pama.New().CurrentPatches()
if err != nil {
log.Errorf("failed to get current patches: %v", err)
return nil
}
return commands.FilterList(patches, arg, nil)
}
func (r Drop) Execute(args []string) error {
patch := r.Tag
err := pama.New().DropPatch(patch)
if err != nil {
return err
}
app.PushStatus(fmt.Sprintf("Patch %s has been dropped", patch),
10*time.Second)
return nil
}
+140
View File
@@ -0,0 +1,140 @@
package patch
import (
"errors"
"fmt"
"net/textproto"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/account"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/go-opt/v2"
)
type Find struct {
Filter bool `opt:"-f" desc:"Filter message list instead of search."`
Commit []string `opt:"..." required:"true" complete:"Complete" desc:"Search for <commit-ish>."`
}
func init() {
register(Find{})
}
func (Find) Description() string {
return "Search for applied patches."
}
func (Find) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Find) Aliases() []string {
return []string{"find"}
}
func (*Find) Complete(arg string) []string {
m := pama.New()
p, err := m.CurrentProject()
if err != nil {
return nil
}
options := make([]string, len(p.Commits))
for i, c := range p.Commits {
options[i] = fmt.Sprintf("%-6.6s %s", c.ID, c.Subject)
}
return commands.FilterList(options, arg, nil)
}
func (s Find) Execute(_ []string) error {
m := pama.New()
p, err := m.CurrentProject()
if err != nil {
return err
}
if len(s.Commit) == 0 {
return errors.New("missing commit hash")
}
lexed := opt.LexArgs(strings.TrimSpace(s.Commit[0]))
hash, err := lexed.ArgSafe(0)
if err != nil {
return err
}
if len(hash) < 4 {
return errors.New("Commit hash is too short.")
}
var c models.Commit
for _, commit := range p.Commits {
if strings.Contains(commit.ID, hash) {
c = commit
break
}
}
if c.ID == "" {
var err error
c, err = m.Find(hash, p)
if err != nil {
return err
}
}
// If Message-Id is provided, find it in store
if c.MessageId != "" {
if selectMessageId(c.MessageId) {
return nil
}
}
// Fallback to a search based on the subject line
args := []string{"search"}
if s.Filter {
args[0] = "filter"
}
headers := make(textproto.MIMEHeader)
args = append(args, fmt.Sprintf("-H Subject:%s", c.Subject))
headers.Add("Subject", c.Subject)
cmd := account.SearchFilter{
Headers: headers,
}
return cmd.Execute(args)
}
func selectMessageId(msgid string) bool {
acct := app.SelectedAccount()
if acct == nil {
return false
}
store := acct.Store()
if store == nil {
return false
}
for uid, msg := range store.Messages {
if msg == nil {
continue
}
if msg.RFC822Headers == nil {
continue
}
id, err := msg.RFC822Headers.MessageID()
if err != nil {
continue
}
if id == msgid {
store.Select(uid)
return true
}
}
return false
}
+45
View File
@@ -0,0 +1,45 @@
package patch
import (
"fmt"
"os"
"path/filepath"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
type Init struct {
Force bool `opt:"-f" desc:"Overwrite any existing project."`
Name string `opt:"name" required:"false"`
}
func init() {
register(Init{})
}
func (Init) Description() string {
return "Create a new project."
}
func (Init) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Init) Aliases() []string {
return []string{"init"}
}
func (i Init) Execute(args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("Could not get current directory: %w", err)
}
name := i.Name
if name == "" {
name = filepath.Base(cwd)
}
return pama.New().Init(name, cwd, i.Force)
}
+114
View File
@@ -0,0 +1,114 @@
package patch
import (
"bufio"
"fmt"
"io"
"os/exec"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
)
type List struct {
All bool `opt:"-a" desc:"List all projects."`
}
func init() {
register(List{})
}
func (List) Description() string {
return "List the current project with the tracked patch sets."
}
func (List) Context() commands.CommandContext {
return commands.GLOBAL
}
func (List) Aliases() []string {
return []string{"list", "ls"}
}
func (l List) Execute(args []string) error {
m := pama.New()
current, err := m.CurrentProject()
if err != nil {
return err
}
projects := []models.Project{current}
if l.All {
projects, err = m.Projects("")
if err != nil {
return err
}
}
app.PushStatus(fmt.Sprintf("Current project: %s", current.Name), 30*time.Second)
createWidget := func(r io.Reader) (ui.DrawableInteractive, error) {
pagerCmd, err := app.CmdFallbackSearch(config.PagerCmds(), true)
if err != nil {
return nil, err
}
cmd := opt.SplitArgs(pagerCmd)
pager := exec.Command(cmd[0], cmd[1:]...)
pager.Stdin = r
term, err := app.NewTerminal(pager)
if err != nil {
return nil, err
}
start := time.Now()
term.OnClose = func(err error) {
if time.Since(start) > 250*time.Millisecond {
app.CloseDialog()
return
}
term.OnEvent = func(_ vaxis.Event) bool {
app.CloseDialog()
return true
}
}
return term, nil
}
viewer, err := createWidget(m.NewReader(projects))
if err != nil {
viewer = app.NewListBox(
"Press <Esc> or <Enter> to close. "+
"Start typing to filter.",
numerify(m.NewReader(projects)), app.SelectedAccountUiConfig(),
func(_ string) { app.CloseDialog() },
)
}
app.AddDialog(app.DefaultDialog(
ui.NewBox(viewer, "Patch Management", "",
app.SelectedAccountUiConfig(),
),
))
return nil
}
func numerify(r io.Reader) []string {
var lines []string
nr := 1
scanner := bufio.NewScanner(r)
for scanner.Scan() {
s := scanner.Text()
lines = append(lines, fmt.Sprintf("%3d %s", nr, s))
nr++
}
return lines
}
+88
View File
@@ -0,0 +1,88 @@
package patch
import (
"errors"
"fmt"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/go-opt/v2"
)
var subCommands map[string]commands.Command
func register(cmd commands.Command) {
if subCommands == nil {
subCommands = make(map[string]commands.Command)
}
for _, alias := range cmd.Aliases() {
if subCommands[alias] != nil {
panic("duplicate sub command alias: " + alias)
}
subCommands[alias] = cmd
}
}
type Patch struct {
SubCmd commands.Command `opt:"command" action:"ParseSub" complete:"CompleteSubNames" desc:"Sub command."`
Args string `opt:"..." required:"false" complete:"CompleteSubArgs"`
}
func init() {
commands.Register(Patch{})
}
func (Patch) Description() string {
return "Local patch management commands."
}
func (Patch) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Patch) Aliases() []string {
return []string{"patch"}
}
func (p *Patch) ParseSub(arg string) error {
cmd, ok := subCommands[arg]
if ok {
context := commands.CurrentContext()
if cmd.Context()&context != 0 {
p.SubCmd = cmd
return nil
}
}
return fmt.Errorf("%s unknown sub-command", arg)
}
func (*Patch) CompleteSubNames(arg string) []string {
context := commands.CurrentContext()
options := make([]string, 0, len(subCommands))
for alias, cmd := range subCommands {
if cmd.Context()&context != 0 {
options = append(options, alias)
}
}
return commands.FilterList(options, arg, commands.QuoteSpace)
}
func (p *Patch) CompleteSubArgs(arg string) []string {
if p.SubCmd == nil {
return nil
}
// prepend arbitrary string to arg to work with sub-commands
options, _ := commands.GetCompletions(p.SubCmd, opt.LexArgs("a "+arg))
completions := make([]string, 0, len(options))
for _, o := range options {
completions = append(completions, o.Value)
}
return completions
}
func (p Patch) Execute(args []string) error {
if p.SubCmd == nil {
return errors.New("no subcommand found")
}
a := opt.QuoteArgs(args[1:]...)
return commands.ExecuteCommand(p.SubCmd, a.String())
}
+250
View File
@@ -0,0 +1,250 @@
package patch
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type Rebase struct {
Commit string `opt:"commit" required:"false"`
}
func init() {
register(Rebase{})
}
func (Rebase) Description() string {
return "Rebase the patch data."
}
func (Rebase) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Rebase) Aliases() []string {
return []string{"rebase"}
}
func (r Rebase) Execute(args []string) error {
m := pama.New()
current, err := m.CurrentProject()
if err != nil {
return err
}
baseID := r.Commit
if baseID == "" {
baseID = current.Base.ID
}
commits, err := m.RebaseCommits(current, baseID)
if err != nil {
return err
}
if len(commits) == 0 {
err := m.SaveRebased(current, baseID, nil)
if err != nil {
return fmt.Errorf("No commits to rebase, but saving of new reference failed: %w", err)
}
app.PushStatus("No commits to rebase.", 10*time.Second)
return nil
}
rebase := newRebase(commits)
f, err := os.CreateTemp("", "aerc-patch-rebase-*")
if err != nil {
return err
}
name := f.Name()
_, err = io.Copy(f, rebase.content())
if err != nil {
return err
}
f.Close()
createWidget := func() (ui.DrawableInteractive, error) {
editorCmd, err := app.CmdFallbackSearch(config.EditorCmds(), true)
if err != nil {
return nil, err
}
editor := exec.Command("/bin/sh", "-c", editorCmd+" "+name)
term, err := app.NewTerminal(editor)
if err != nil {
return nil, err
}
term.OnClose = func(_ error) {
app.CloseDialog()
defer os.Remove(name)
defer term.Focus(false)
f, err := os.Open(name)
if err != nil {
app.PushError(fmt.Sprintf("failed to open file: %v", err))
return
}
defer f.Close()
if editor.ProcessState.ExitCode() > 0 {
app.PushError("Quitting rebase without saving.")
return
}
err = m.SaveRebased(current, baseID, rebase.parse(f))
if err != nil {
app.PushError(fmt.Sprintf("Failed to save rebased commits: %v", err))
return
}
app.PushStatus("Successfully rebased.", 10*time.Second)
}
term.Show(true)
term.Focus(true)
return term, nil
}
viewer, err := createWidget()
if err != nil {
return err
}
app.AddDialog(app.DefaultDialog(
ui.NewBox(viewer, fmt.Sprintf("Patch Rebase on %-6.6s", baseID), "",
app.SelectedAccountUiConfig(),
),
))
return nil
}
type rebase struct {
commits []models.Commit
table map[string]models.Commit
order []string
}
func newRebase(commits []models.Commit) *rebase {
return &rebase{
commits: commits,
table: make(map[string]models.Commit),
}
}
const (
header string = ""
footer string = `
# Rebase aerc's patch data. This will not affect the underlying repository in
# any way.
#
# Change the name in the first column to assign a new tag to a commit. To group
# multiple commits, use the same tag name.
#
# An 'untracked' tag indicates that aerc lost track of that commit, either due
# to a commit-hash change or because that commit was applied outside of aerc.
#
# Do not change anything else besides the tag names (first column).
#
# Do not reorder the lines. The ordering should remain as in the repository.
#
# If you remove a line or keep an 'untracked' tag, those commits will be removed
# from aerc's patch tracking.
#
`
)
func (r *rebase) content() io.Reader {
var buf bytes.Buffer
buf.WriteString(header)
for _, c := range r.commits {
tag := c.Tag
if tag == "" {
tag = models.Untracked
}
shortHash := fmt.Sprintf("%6.6s", c.ID)
buf.WriteString(
fmt.Sprintf("%-12s %6.6s %s\n", tag, shortHash, c.Info()))
r.table[shortHash] = c
r.order = append(r.order, shortHash)
}
buf.WriteString(footer)
return &buf
}
func (r *rebase) parse(reader io.Reader) []models.Commit {
var commits []models.Commit
var hashes []string
scanner := bufio.NewScanner(reader)
duplicated := make(map[string]struct{})
for scanner.Scan() {
s := scanner.Text()
i := strings.Index(s, "#")
if i >= 0 {
s = s[:i]
}
if strings.TrimSpace(s) == "" {
continue
}
fds := strings.Fields(s)
if len(fds) < 2 {
continue
}
tag, shortHash := fds[0], fds[1]
if tag == models.Untracked {
continue
}
_, dedup := duplicated[shortHash]
if dedup {
log.Warnf("rebase: skipping duplicated hash: %s", shortHash)
continue
}
hashes = append(hashes, shortHash)
c, ok := r.table[shortHash]
if !ok {
log.Errorf("Looks like the commit hashes were changed "+
"during the rebase. Dropping: %v", shortHash)
continue
}
log.Tracef("save commit %s with tag %s", shortHash, tag)
c.Tag = tag
commits = append(commits, c)
duplicated[shortHash] = struct{}{}
}
reorder(commits, hashes, r.order)
return commits
}
func reorder(toSort []models.Commit, now []string, by []string) {
byMap := make(map[string]int)
for i, s := range by {
byMap[s] = i
}
complete := true
for _, s := range now {
_, ok := byMap[s]
complete = complete && ok
}
if !complete {
return
}
sort.SliceStable(toSort, func(i, j int) bool {
return byMap[now[i]] < byMap[now[j]]
})
}
+114
View File
@@ -0,0 +1,114 @@
package patch
import (
"fmt"
"reflect"
"strings"
"testing"
"git.sr.ht/~rjarry/aerc/lib/pama/models"
)
func TestRebase_reorder(t *testing.T) {
newCommits := func(order []string) []models.Commit {
var commits []models.Commit
for _, s := range order {
commits = append(commits, models.Commit{ID: s})
}
return commits
}
tests := []struct {
name string
commits []models.Commit
now []string
by []string
want []models.Commit
}{
{
name: "nothing to reorder",
commits: newCommits([]string{"1", "2", "3"}),
now: []string{"1", "2", "3"},
by: []string{"1", "2", "3"},
want: newCommits([]string{"1", "2", "3"}),
},
{
name: "reorder",
commits: newCommits([]string{"1", "3", "2"}),
now: []string{"1", "3", "2"},
by: []string{"1", "2", "3"},
want: newCommits([]string{"1", "2", "3"}),
},
{
name: "reorder inverted",
commits: newCommits([]string{"3", "2", "1"}),
now: []string{"3", "2", "1"},
by: []string{"1", "2", "3"},
want: newCommits([]string{"1", "2", "3"}),
},
{
name: "changed hash: do not sort",
commits: newCommits([]string{"1", "6", "3"}),
now: []string{"1", "6", "3"},
by: []string{"1", "2", "3"},
want: newCommits([]string{"1", "6", "3"}),
},
}
for _, test := range tests {
reorder(test.commits, test.now, test.by)
if !reflect.DeepEqual(test.commits, test.want) {
t.Errorf("test '%s' failed to reorder: got %v but "+
"want %v", test.name, test.commits, test.want)
}
}
}
func newCommit(id, subj, tag string) models.Commit {
return models.Commit{
ID: id,
Subject: subj,
Tag: tag,
}
}
func TestRebase_parse(t *testing.T) {
input := `
# some header info
hello_v1 123 same info
hello_v1 456 same info
untracked 789 same info
hello_v2 012 diff info
untracked 345 diff info # not very useful comment
# some footer info
`
commits := []models.Commit{
newCommit("123123", "same info", "hello_v1"),
newCommit("456456", "same info", "hello_v1"),
newCommit("789789", "same info", models.Untracked),
newCommit("012012", "diff info", "hello_v2"),
newCommit("345345", "diff info", models.Untracked),
}
var order []string
for _, c := range commits {
order = append(order, fmt.Sprintf("%3.3s", c.ID))
}
table := make(map[string]models.Commit)
for i, shortId := range order {
table[shortId] = commits[i]
}
rebase := &rebase{
commits: commits,
table: table,
order: order,
}
results := rebase.parse(strings.NewReader(input))
if len(results) != 3 {
t.Errorf("failed to return correct number of commits: "+
"got %d but wanted 3", len(results))
}
}
+62
View File
@@ -0,0 +1,62 @@
package patch
import (
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
type Switch struct {
Project string `opt:"project" complete:"Complete" desc:"Project name."`
}
func init() {
register(Switch{})
}
func (Switch) Description() string {
return "Switch context to the specified project."
}
func (Switch) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Switch) Aliases() []string {
return []string{"switch"}
}
func (s Switch) Complete(arg string) []string {
m := pama.New()
names, err := m.Names()
if err != nil {
log.Errorf("failed to get completion: %v", err)
return nil
}
cur, err := m.CurrentProject()
if err == nil {
i := 0
for ; i < len(names); i++ {
if cur.Name == names[i] {
names = append(names[:i], names[i+1:]...)
break
}
}
}
return commands.FilterList(names, arg, nil)
}
func (s Switch) Execute(_ []string) error {
name := s.Project
err := pama.New().SwitchProject(name)
if err != nil {
return err
}
app.PushStatus(fmt.Sprintf("Project switched to '%s'", name),
10*time.Second)
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package patch
import (
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
type Term struct {
Cmd []string `opt:"..." required:"false"`
}
func init() {
register(Term{})
}
func (Term) Description() string {
return "Open a shell or run a command in the current project's directory."
}
func (Term) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Term) Aliases() []string {
return []string{"term"}
}
func (t Term) Execute(_ []string) error {
p, err := pama.New().CurrentProject()
if err != nil {
return err
}
return commands.TermCoreDirectory(t.Cmd, p.Root)
}
+62
View File
@@ -0,0 +1,62 @@
package patch
import (
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/pama"
)
type Unlink struct {
Tag string `opt:"tag" required:"false" complete:"Complete" desc:"Project tag name."`
}
func init() {
register(Unlink{})
}
func (Unlink) Description() string {
return "Delete all patch tracking data for the specified project."
}
func (Unlink) Context() commands.CommandContext {
return commands.GLOBAL
}
func (Unlink) Aliases() []string {
return []string{"unlink"}
}
func (*Unlink) Complete(arg string) []string {
names, err := pama.New().Names()
if err != nil {
log.Errorf("failed to get completion: %v", err)
return nil
}
return commands.FilterList(names, arg, nil)
}
func (d Unlink) Execute(args []string) error {
m := pama.New()
name := d.Tag
if name == "" {
p, err := m.CurrentProject()
if err != nil {
return err
}
name = p.Name
}
err := m.Unlink(name)
if err != nil {
return err
}
app.PushStatus(fmt.Sprintf("Project '%s' unlinked.", name),
10*time.Second)
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package commands
import (
"git.sr.ht/~rjarry/aerc/app"
)
type PinTab struct{}
func init() {
Register(PinTab{})
}
func (PinTab) Description() string {
return "Move the current tab to the left and mark it as pinned."
}
func (PinTab) Context() CommandContext {
return GLOBAL
}
func (PinTab) Aliases() []string {
return []string{"pin-tab", "unpin-tab"}
}
func (PinTab) Execute(args []string) error {
switch args[0] {
case "pin-tab":
app.PinTab()
case "unpin-tab":
app.UnpinTab()
}
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package commands
import (
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rjarry/aerc/app"
)
type Prompt struct {
Text string `opt:"text"`
Cmd []string `opt:"..." complete:"CompleteCommand" desc:"Command name."`
}
func init() {
Register(Prompt{})
}
func (Prompt) Description() string {
return "Prompt for user input and execute a command."
}
func (Prompt) Context() CommandContext {
return GLOBAL
}
func (Prompt) Aliases() []string {
return []string{"prompt"}
}
func (*Prompt) CompleteCommand(arg string) []string {
return FilterList(ActiveCommandNames(), arg, nil)
}
func (p Prompt) Execute(args []string) error {
cmd := opt.QuoteArgs(p.Cmd...)
app.RegisterPrompt(p.Text, cmd.String())
return nil
}
+35
View File
@@ -0,0 +1,35 @@
package commands
import (
"os"
"time"
"git.sr.ht/~rjarry/aerc/app"
)
type PrintWorkDir struct{}
func init() {
Register(PrintWorkDir{})
}
func (PrintWorkDir) Description() string {
return "Display aerc's current working directory."
}
func (PrintWorkDir) Context() CommandContext {
return GLOBAL
}
func (PrintWorkDir) Aliases() []string {
return []string{"pwd"}
}
func (PrintWorkDir) Execute(args []string) error {
pwd, err := os.Getwd()
if err != nil {
return err
}
app.PushStatus(pwd, 10*time.Second)
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package commands
import (
"fmt"
"git.sr.ht/~rjarry/aerc/commands/mode"
)
type Quit struct {
Force bool `opt:"-f" desc:"Force quit even if a task is pending."`
}
func init() {
Register(Quit{})
}
func (Quit) Description() string {
return "Exit aerc."
}
func (Quit) Context() CommandContext {
return GLOBAL
}
func (Quit) Aliases() []string {
return []string{"quit", "q", "exit"}
}
type ErrorExit int
func (err ErrorExit) Error() string {
return "exit"
}
func (q Quit) Execute(args []string) error {
if q.Force || mode.QuitAllowed() {
return ErrorExit(1)
}
return fmt.Errorf("A task is not done yet. Use -f to force an exit.")
}
+26
View File
@@ -0,0 +1,26 @@
package commands
import "git.sr.ht/~rjarry/aerc/lib/ui"
type Redraw struct{}
func init() {
Register(Redraw{})
}
func (Redraw) Description() string {
return "Force a full redraw of the screen."
}
func (Redraw) Context() CommandContext {
return GLOBAL
}
func (Redraw) Aliases() []string {
return []string{"redraw"}
}
func (Redraw) Execute(args []string) error {
ui.QueueRefresh()
return nil
}
+194
View File
@@ -0,0 +1,194 @@
package commands
import (
"os"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type Reload struct {
Binds bool `opt:"-B" desc:"Reload binds.conf."`
Conf bool `opt:"-C" desc:"Reload aerc.conf."`
Style string `opt:"-s" complete:"CompleteStyle" desc:"Reload the specified styleset."`
}
func init() {
Register(Reload{})
}
func (Reload) Description() string {
return "Hot-reload configuration files."
}
func (r *Reload) CompleteStyle(s string) []string {
var files []string
for _, dir := range config.Ui.StyleSetDirs {
entries, err := os.ReadDir(dir)
if err != nil {
log.Debugf("could not read directory '%s': %v", dir,
err)
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
files = append(files, e.Name())
}
}
return FilterList(files, s, nil)
}
func (Reload) Context() CommandContext {
return GLOBAL
}
func (Reload) Aliases() []string {
return []string{"reload"}
}
func (r Reload) Execute(args []string) error {
if !r.Binds && !r.Conf && r.Style == "" {
r.Binds = true
r.Conf = true
r.Style = config.Ui.StyleSetName
}
reconfigure := false
if r.Binds {
f, err := config.ReloadBinds()
if err != nil {
return err
}
app.PushSuccess("Binds reloaded: " + f)
}
if r.Conf {
f, err := config.ReloadConf()
if err != nil {
return err
}
app.PushSuccess("Conf reloaded: " + f)
reconfigure = true
}
if r.Style != "" {
config.Ui.ClearCache()
config.Ui.StyleSetName = r.Style
err := config.Ui.LoadStyle()
if err != nil {
return err
}
app.PushSuccess("Styleset: " + r.Style)
reconfigure = true
}
if !reconfigure {
return nil
}
// reload account views and message stores
for _, name := range app.AccountNames() {
// rebuild account view
view, err := app.Account(name)
if err != nil {
continue
}
dirlist := view.Directories()
if dirlist == nil {
continue
}
wantTree := config.Ui.ForAccount(name).DirListTree
dirlist = adjustDirlist(dirlist, wantTree)
view.SetDirectories(dirlist)
// now rebuild grid with correct dirlist
view.Configure()
// reconfigure the message stores
for _, dir := range dirlist.List() {
store, ok := dirlist.MsgStore(dir)
if !ok {
continue
}
uiConf := dirlist.UiConfig(dir)
store.Configure(view.SortCriteria(uiConf))
}
ui.Invalidate()
}
// reload message viewers
doTabs(func(tab *ui.Tab) {
mv, ok := tab.Content.(*app.MessageViewer)
if !ok {
return
}
reloaded, err := app.NewMessageViewer(
mv.SelectedAccount(),
mv.MessageView(),
)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, reloaded, tab.Name, false)
})
// reload composers
doTabs(func(tab *ui.Tab) {
c, ok := tab.Content.(*app.Composer)
if !ok {
return
}
_ = c.SwitchAccount(c.Account())
})
return nil
}
func adjustDirlist(d app.DirectoryLister, wantTree bool) app.DirectoryLister {
switch d := d.(type) {
case *app.DirectoryList:
if wantTree {
log.Tracef("dirlist: build tree")
tree := app.NewDirectoryTree(d)
tree.SelectedMsgStore()
return tree
}
log.Tracef("dirlist: already dirlist")
return d
case *app.DirectoryTree:
if !wantTree {
log.Tracef("dirtree: get dirlist")
return d.DirectoryList
}
log.Tracef("dirtree: already tree")
return d
default:
return d
}
}
func doTabs(do func(*ui.Tab)) {
var tabname string
if t := app.SelectedTab(); t != nil {
tabname = t.Name
}
for i := range app.TabNames() {
tab := app.GetTab(i)
if tab == nil {
continue
}
do(tab)
}
if tabname != "" {
app.SelectTab(tabname)
}
}
+57
View File
@@ -0,0 +1,57 @@
package commands
import (
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rockorager/vaxis"
"github.com/pkg/errors"
)
type SendKeys struct {
Keys string `opt:"..."`
}
func init() {
Register(SendKeys{})
}
func (SendKeys) Description() string {
return "Send keystrokes to the currently visible terminal."
}
func (SendKeys) Context() CommandContext {
return GLOBAL
}
func (SendKeys) Aliases() []string {
return []string{"send-keys"}
}
func (s SendKeys) Execute(args []string) error {
tab, ok := app.SelectedTabContent().(app.HasTerminal)
if !ok {
return errors.New("There is no terminal here")
}
term := tab.Terminal()
if term == nil {
return errors.New("The terminal is not active")
}
keys2send, err := config.ParseKeyStrokes(s.Keys)
if err != nil {
return errors.Wrapf(err, "Unable to parse keystroke: %q", s.Keys)
}
for _, key := range keys2send {
ev := vaxis.Key{
Keycode: key.Key,
Modifiers: key.Modifiers,
}
term.Event(ev)
}
term.Invalidate()
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package commands
import "git.sr.ht/~rjarry/aerc/lib/ui"
type Suspend struct{}
func init() {
Register(Suspend{})
}
func (Suspend) Description() string {
return "Suspend the aerc process."
}
func (Suspend) Context() CommandContext {
return GLOBAL
}
func (Suspend) Aliases() []string {
return []string{"suspend"}
}
func (Suspend) Execute(args []string) error {
ui.QueueSuspend()
return nil
}

Some files were not shown because too many files have changed in this diff Show More