init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user