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