init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
package msg
import (
"fmt"
"strings"
"sync"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
const (
ARCHIVE_FLAT = "flat"
ARCHIVE_YEAR = "year"
ARCHIVE_MONTH = "month"
)
var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH}
type Archive struct {
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType" desc:"Archiving scheme."`
}
func (a *Archive) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
a.MultiFileStrategy = &mfs
}
return nil
}
func (a *Archive) ParseArchiveType(arg string) error {
for _, t := range ARCHIVE_TYPES {
if t == arg {
a.Type = arg
return nil
}
}
return fmt.Errorf("invalid archive type")
}
func init() {
commands.Register(Archive{})
}
func (Archive) Description() string {
return "Move the selected message to the archive."
}
func (Archive) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Archive) Aliases() []string {
return []string{"archive"}
}
func (Archive) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (*Archive) CompleteType(arg string) []string {
return commands.FilterList(ARCHIVE_TYPES, arg, nil)
}
func (a Archive) Execute(args []string) error {
h := newHelper()
msgs, err := h.messages()
if err != nil {
return err
}
err = archive(msgs, a.MultiFileStrategy, a.Type)
return err
}
func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy,
archiveType string,
) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
var uids []models.UID
for _, msg := range msgs {
uids = append(uids, msg.Uid)
}
archiveDir := acct.AccountConfig().Archive
marker := store.Marker()
marker.ClearVisualMark()
next := findNextNonDeleted(uids, store)
var uidMap map[string][]models.UID
switch archiveType {
case ARCHIVE_MONTH:
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
dir := strings.Join([]string{
archiveDir,
fmt.Sprintf("%d", msg.Envelope.Date.Year()),
fmt.Sprintf("%02d", msg.Envelope.Date.Month()),
}, app.SelectedAccount().Worker().PathSeparator(),
)
return dir
})
case ARCHIVE_YEAR:
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
dir := strings.Join([]string{
archiveDir,
fmt.Sprintf("%v", msg.Envelope.Date.Year()),
}, app.SelectedAccount().Worker().PathSeparator(),
)
return dir
})
case ARCHIVE_FLAT:
uidMap = make(map[string][]models.UID)
uidMap[archiveDir] = commands.UidsFromMessageInfos(msgs)
}
var wg sync.WaitGroup
wg.Add(len(uidMap))
success := true
for dir, uids := range uidMap {
store.Move(uids, dir, true, mfs, func(
msg types.WorkerMessage,
) {
switch msg := msg.(type) {
case *types.Done:
wg.Done()
case *types.Error:
app.PushError(msg.Error.Error())
success = false
wg.Done()
marker.Remark()
}
})
}
// we need to do that in the background, else we block the main thread
go func() {
defer log.PanicHandler()
wg.Wait()
if success {
var s string
if len(uids) > 1 {
s = "%d messages archived to %s"
} else {
s = "%d message archived to %s"
}
app.PushStatus(fmt.Sprintf(s, len(uids), archiveDir), 10*time.Second)
handleDone(acct, next, store)
}
}()
return nil
}
func groupBy(msgs []*models.MessageInfo,
grouper func(*models.MessageInfo) string,
) map[string][]models.UID {
m := make(map[string][]models.UID)
for _, msg := range msgs {
group := grouper(msg)
m[group] = append(m[group], msg.Uid)
}
return m
}
+214
View File
@@ -0,0 +1,214 @@
package msg
import (
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/emersion/go-message/mail"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/mode"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/send"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Bounce struct {
Account string `opt:"-A" complete:"CompleteAccount" desc:"Account from which to re-send the message."`
To []string `opt:"..." required:"true" complete:"CompleteTo" desc:"Recipient from address book."`
}
func init() {
commands.Register(Bounce{})
}
func (Bounce) Description() string {
return "Re-send the selected message(s) to the specified addresses."
}
func (Bounce) Aliases() []string {
return []string{"bounce", "resend"}
}
func (*Bounce) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (*Bounce) CompleteTo(arg string) []string {
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
}
func (Bounce) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (b Bounce) Execute(args []string) error {
if len(b.To) == 0 {
return errors.New("No recipients specified")
}
addresses := strings.Join(b.To, ", ")
app.PushStatus("Bouncing to "+addresses, 10*time.Second)
widget := app.SelectedTabContent().(app.ProvidesMessage)
var err error
acct := widget.SelectedAccount()
if b.Account != "" {
acct, err = app.Account(b.Account)
}
switch {
case err != nil:
return fmt.Errorf("Failed to select account %q: %w", b.Account, err)
case acct == nil:
return errors.New("No account selected")
}
store := widget.Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
config := acct.AccountConfig()
outgoing, err := config.Outgoing.ConnectionString()
if err != nil {
return errors.Wrap(err, "ReadCredentials()")
}
if outgoing == "" {
return errors.New("No outgoing mail transport configured for this account")
}
uri, err := url.Parse(outgoing)
if err != nil {
return errors.Wrap(err, "url.Parse()")
}
rcpts, err := mail.ParseAddressList(addresses)
if err != nil {
return errors.Wrap(err, "ParseAddressList()")
}
var domain string
if domain_, ok := config.Params["smtp-domain"]; ok {
domain = domain_
}
hostname, err := send.GetMessageIdHostname(config.SendWithHostname, config.From)
if err != nil {
return errors.Wrap(err, "GetMessageIdHostname()")
}
// According to RFC2822, all of the resent fields corresponding
// to a particular resending of the message SHOULD be together.
// Each new set of resent fields is prepended to the message;
// that is, the most recent set of resent fields appear earlier in the
// message.
headers := fmt.Sprintf("Resent-From: %s\r\n", config.From)
headers += "Resent-Date: %s\r\n"
headers += "Resent-Message-ID: <%s>\r\n"
headers += fmt.Sprintf("Resent-To: %s\r\n", addresses)
helper := newHelper()
uids, err := helper.markedOrSelectedUids()
if err != nil {
return err
}
mode.NoQuit()
marker := store.Marker()
marker.ClearVisualMark()
errCh := make(chan error)
store.FetchFull(uids, func(fm *types.FullMessage) {
defer log.PanicHandler()
var header mail.Header
var msgId string
var err, errClose error
uid := fm.Content.Uid
msg := store.Messages[uid]
if msg == nil {
errCh <- fmt.Errorf("no message info: %v", uid)
return
}
if err = header.GenerateMessageIDWithHostname(hostname); err != nil {
errCh <- errors.Wrap(err, "GenerateMessageIDWithHostname()")
return
}
if msgId, err = header.MessageID(); err != nil {
errCh <- errors.Wrap(err, "MessageID()")
return
}
reader := strings.NewReader(fmt.Sprintf(headers,
time.Now().Format(time.RFC1123Z), msgId))
go func() {
defer log.PanicHandler()
defer func() { errCh <- err }()
var sender io.WriteCloser
log.Debugf("Bouncing email <%s> to %s",
msg.Envelope.MessageId, addresses)
if sender, err = send.NewSender(acct.Worker(), uri,
domain, config.From, rcpts, nil); err != nil {
return
}
defer func() {
errClose = sender.Close()
// If there has already been an error,
// we don't want to clobber it.
if err == nil {
err = errClose
} else if errClose != nil {
app.PushError(errClose.Error())
}
}()
if _, err = io.Copy(sender, reader); err != nil {
return
}
_, err = io.Copy(sender, fm.Content.Reader)
}()
})
go func() {
defer log.PanicHandler()
defer mode.NoQuitDone()
var total, success int
for err = range errCh {
if err != nil {
app.PushError(err.Error())
} else {
success++
}
total++
if total == len(uids) {
break
}
}
if success != total {
marker.Remark()
app.PushError(fmt.Sprintf("Failed to bounce %d of the messages",
total-success))
} else {
plural := ""
if success > 1 {
plural = "s"
}
app.PushStatus(fmt.Sprintf("Bounced %d message%s",
success, plural), 10*time.Second)
}
}()
return nil
}
+201
View File
@@ -0,0 +1,201 @@
package msg
import (
"bytes"
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib"
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-message/mail"
"github.com/pkg/errors"
)
type Copy struct {
CreateFolders bool `opt:"-p" desc:"Create folder if it does not exist."`
Decrypt bool `opt:"-d" desc:"Decrypt the message before copying."`
Account string `opt:"-a" complete:"CompleteAccount" desc:"Copy to the specified account."`
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
}
func init() {
commands.Register(Copy{})
}
func (Copy) Description() string {
return "Copy the selected message(s) to the specified folder."
}
func (Copy) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Copy) Aliases() []string {
return []string{"cp", "copy"}
}
func (c *Copy) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
c.MultiFileStrategy = &mfs
}
return nil
}
func (*Copy) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (c *Copy) CompleteFolder(arg string) []string {
var acct *app.AccountView
if len(c.Account) > 0 {
acct, _ = app.Account(c.Account)
} else {
acct = app.SelectedAccount()
}
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, nil)
}
func (Copy) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (c Copy) Execute(args []string) error {
h := newHelper()
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
// when the decrypt flag is set, add the current account to c.Account to
// ensure that we do not take the store.Copy route.
if c.Decrypt {
if acct := app.SelectedAccount(); acct != nil {
c.Account = acct.Name()
} else {
return errors.New("no account name found")
}
}
if len(c.Account) == 0 {
store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy,
func(msg types.WorkerMessage) {
c.CallBack(msg, uids, store)
})
return nil
}
destAcct, err := app.Account(c.Account)
if err != nil {
return err
}
destStore := destAcct.Store()
if destStore == nil {
app.PushError(fmt.Sprintf("No message store in %s", c.Account))
return nil
}
var messages []*types.FullMessage
fetchDone := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
if fm == nil {
return
}
if c.Decrypt {
h := new(mail.Header)
msg, ok := store.Messages[fm.Content.Uid]
if ok {
h = msg.RFC822Headers
}
cleartext, err := cryptoutil.Cleartext(fm.Content.Reader, *h)
if err != nil {
log.Debugf("could not decrypt message %v", fm.Content.Uid)
} else {
fm.Content.Reader = bytes.NewReader(cleartext)
}
}
messages = append(messages, fm)
if len(messages) == len(uids) {
fetchDone <- true
}
})
// Since this operation can take some time with some backends
// (e.g. IMAP), provide some feedback to inform the user that
// something is happening
app.PushStatus("Copying messages...", 10*time.Second)
go func() {
defer log.PanicHandler()
select {
case <-fetchDone:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
for _, fm := range messages {
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(fm.Content.Reader)
if err != nil {
log.Warnf("failed to read message: %v", err)
continue
}
destStore.Append(
c.Folder,
models.SeenFlag,
time.Now(),
buf,
buf.Len(),
func(msg types.WorkerMessage) {
c.CallBack(msg, uids, store)
},
)
}
}()
return nil
}
func (c Copy) CallBack(msg types.WorkerMessage, uids []models.UID, store *lib.MessageStore) {
dest := c.Folder
if len(c.Account) != 0 {
dest = fmt.Sprintf("%s in %s", c.Folder, c.Account)
}
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages copied to %s"
} else {
s = "%d message copied to %s"
}
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
}
+164
View File
@@ -0,0 +1,164 @@
package msg
import (
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Delete struct {
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
}
func init() {
commands.Register(Delete{})
}
func (Delete) Description() string {
return "Delete the selected message(s)."
}
func (Delete) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Delete) Aliases() []string {
return []string{"delete", "delete-message"}
}
func (d *Delete) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
d.MultiFileStrategy = &mfs
}
return nil
}
func (Delete) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (d Delete) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
acct, err := h.account()
if err != nil {
return err
}
sel := store.Selected()
marker := store.Marker()
marker.ClearVisualMark()
// caution, can be nil
next := findNextNonDeleted(uids, store)
store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages deleted"
} else {
s = "%d message deleted"
}
app.PushStatus(fmt.Sprintf(s, len(uids)), 10*time.Second)
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
if isMsgView {
if !config.Ui.NextMessageOnDelete {
app.RemoveTab(h.msgProvider, true)
} else {
// no more messages in the list
if next == nil {
app.RemoveTab(h.msgProvider, true)
acct.Messages().Select(-1)
ui.Invalidate()
return
}
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
store, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
nextMv, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
})
}
} else {
if next == nil {
// We deleted the last message, select the new last message
// instead of the first message
acct.Messages().Select(-1)
}
}
case *types.Error:
marker.Remark()
store.Select(sel.Uid)
app.PushError(msg.Error.Error())
case *types.Unsupported:
marker.Remark()
store.Select(sel.Uid)
// notmuch doesn't support it, we want the user to know
app.PushError(" error, unsupported for this worker")
}
})
return nil
}
func findNextNonDeleted(deleted []models.UID, store *lib.MessageStore) *models.MessageInfo {
var next, previous *models.MessageInfo
stepper := []func(){store.Next, store.Prev}
for _, stepFn := range stepper {
previous = nil
for {
next = store.Selected()
if next != nil && !contains(deleted, next.Uid) {
if _, deleted := store.Deleted[next.Uid]; !deleted {
return next
}
}
if next == nil || previous == next {
// If previous == next, this is the last
// message. Set next to nil either way
next = nil
break
}
stepFn()
previous = next
}
}
if next != nil {
store.Select(next.Uid)
}
return next
}
func contains(uids []models.UID, uid models.UID) bool {
for _, item := range uids {
if item == uid {
return true
}
}
return false
}
+134
View File
@@ -0,0 +1,134 @@
package msg
import (
"errors"
"fmt"
"strings"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
)
type Envelope struct {
Header bool `opt:"-h" desc:"Show all header fields."`
Format string `opt:"-s" default:"%-20.20s: %s" desc:"Format specifier."`
}
func init() {
commands.Register(Envelope{})
}
func (Envelope) Description() string {
return "Open the message envelope in a dialog popup."
}
func (Envelope) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Envelope) Aliases() []string {
return []string{"envelope"}
}
func (e Envelope) Execute(args []string) error {
provider, ok := app.SelectedTabContent().(app.ProvidesMessages)
if !ok {
return fmt.Errorf("current tab does not implement app.ProvidesMessage interface")
}
acct := provider.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
var list []string
if msg, err := provider.SelectedMessage(); err != nil {
return err
} else {
if msg != nil {
if e.Header {
list = parseHeader(msg, e.Format)
} else {
list = parseEnvelope(msg, e.Format,
acct.UiConfig().TimestampFormat)
}
} else {
return fmt.Errorf("Selected message is empty.")
}
}
app.AddDialog(app.DefaultDialog(
app.NewListBox(
"Message Envelope. Press <Esc> or <Enter> to close. "+
"Start typing to filter.",
list,
app.SelectedAccountUiConfig(),
func(_ string) {
app.CloseDialog()
},
),
))
return nil
}
func parseEnvelope(msg *models.MessageInfo, fmtStr, fmtTime string,
) (result []string) {
if envlp := msg.Envelope; envlp != nil {
addStr := func(key, text string) {
result = append(result, fmt.Sprintf(fmtStr, key, text))
}
addAddr := func(key string, ls []*mail.Address) {
for _, l := range ls {
result = append(result,
fmt.Sprintf(fmtStr, key,
format.AddressForHumans(l)))
}
}
addStr("Date", envlp.Date.Format(fmtTime))
addStr("Subject", envlp.Subject)
addStr("Message-Id", envlp.MessageId)
addAddr("From", envlp.From)
addAddr("To", envlp.To)
addAddr("ReplyTo", envlp.ReplyTo)
addAddr("Cc", envlp.Cc)
addAddr("Bcc", envlp.Bcc)
}
return
}
func parseHeader(msg *models.MessageInfo, fmtStr string) (result []string) {
if h := msg.RFC822Headers; h != nil {
hf := h.Fields()
for hf.Next() {
text, err := hf.Text()
if err != nil {
log.Errorf(err.Error())
text = hf.Value()
}
result = append(result,
headerExpand(fmtStr, hf.Key(), text)...)
}
}
return
}
func headerExpand(fmtStr, key, text string) []string {
var result []string
switch strings.ToLower(key) {
case "to", "from", "bcc", "cc":
for _, item := range strings.Split(text, ",") {
result = append(result, fmt.Sprintf(fmtStr, key,
strings.TrimSpace(item)))
}
default:
result = append(result, fmt.Sprintf(fmtStr, key, text))
}
return result
}
+73
View File
@@ -0,0 +1,73 @@
package msg
import (
"errors"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type Fold struct {
All bool `opt:"-a" desc:"Fold/unfold all threads."`
Toggle bool `opt:"-t" desc:"Toggle between folded/unfolded."`
}
func init() {
commands.Register(Fold{})
}
func (Fold) Description() string {
return "Collapse or expand the thread children of the selected message."
}
func (Fold) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Fold) Aliases() []string {
return []string{"fold", "unfold"}
}
func (f Fold) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
if f.All {
point := store.SelectedUid()
uids := store.Uids()
for _, uid := range uids {
t, err := store.Thread(uid)
if err == nil && t.Parent == nil {
switch args[0] {
case "fold":
err = store.Fold(uid, f.Toggle)
case "unfold":
err = store.Unfold(uid, f.Toggle)
}
}
if err != nil {
return err
}
}
store.Select(point)
ui.Invalidate()
return err
}
msg := store.Selected()
if msg == nil {
return errors.New("No message selected")
}
switch args[0] {
case "fold":
err = store.Fold(msg.Uid, f.Toggle)
case "unfold":
err = store.Unfold(msg.Uid, f.Toggle)
}
ui.Invalidate()
return err
}
+264
View File
@@ -0,0 +1,264 @@
package msg
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"math/rand"
"os"
"path"
"strings"
"sync"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"github.com/emersion/go-message/mail"
)
type forward struct {
AttachAll bool `opt:"-A" desc:"Forward the message and all attachments."`
AttachFull bool `opt:"-F" desc:"Forward the full message as an RFC 2822 attachment."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
To []string `opt:"..." required:"false" complete:"CompleteTo" desc:"Recipient from address book."`
}
func init() {
commands.Register(forward{})
}
func (forward) Description() string {
return "Open the composer to forward the selected message to another recipient."
}
func (forward) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (forward) Aliases() []string {
return []string{"forward"}
}
func (*forward) CompleteTemplate(arg string) []string {
return commands.GetTemplates(arg)
}
func (*forward) CompleteTo(arg string) []string {
return commands.GetAddress(arg)
}
func (f forward) Execute(args []string) error {
if f.AttachAll && f.AttachFull {
return errors.New("Options -A and -F are mutually exclusive")
}
editHeaders := (config.Compose.EditHeaders || f.Edit) && !f.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
msg, err := widget.SelectedMessage()
if err != nil {
return err
}
log.Debugf("Forwarding email <%s>", msg.Envelope.MessageId)
h := &mail.Header{}
subject := "Fwd: " + msg.Envelope.Subject
h.SetSubject(subject)
var tolist []*mail.Address
to := strings.Join(f.To, ", ")
if strings.Contains(to, "@") {
tolist, err = mail.ParseAddressList(to)
if err != nil {
return fmt.Errorf("invalid to address(es): %w", err)
}
}
if len(tolist) > 0 {
h.SetAddressList("to", tolist)
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
addTab := func() (*app.Composer, error) {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
f.Template, h, &original, nil)
if err != nil {
app.PushError("Error: " + err.Error())
return nil, err
}
composer.Tab = app.NewTab(composer, subject)
switch {
case f.SkipEditor:
composer.Terminal().Close()
case !h.Has("to"):
composer.FocusEditor("to")
default:
composer.FocusTerminal()
}
return composer, nil
}
mv, isMsgViewer := widget.(*app.MessageViewer)
store := widget.Store()
noStore := store == nil
if noStore && !isMsgViewer {
return errors.New("Cannot perform action. Messages still loading")
}
if f.AttachFull {
tmpDir, err := os.MkdirTemp("", "aerc-tmp-attachment")
if err != nil {
return err
}
tmpFileName := path.Join(tmpDir,
strings.ReplaceAll(fmt.Sprintf("%s.eml", msg.Envelope.Subject), "/", "-"))
var fetchFull func(func(io.Reader))
if isMsgViewer {
fetchFull = mv.MessageView().FetchFull
} else {
fetchFull = func(cb func(io.Reader)) {
store.FetchFull([]models.UID{msg.Uid}, func(fm *types.FullMessage) {
if fm == nil || (fm != nil && fm.Content == nil) {
return
}
cb(fm.Content.Reader)
})
}
}
fetchFull(func(r io.Reader) {
tmpFile, err := os.Create(tmpFileName)
if err != nil {
log.Warnf("failed to create temporary attachment: %v", err)
_, err = addTab()
if err != nil {
log.Warnf("failed to add tab: %v", err)
}
return
}
defer tmpFile.Close()
_, err = io.Copy(tmpFile, r)
if err != nil {
log.Warnf("failed to write to tmpfile: %v", err)
return
}
composer, err := addTab()
if err != nil {
return
}
composer.AddAttachment(tmpFileName)
composer.OnClose(func(c *app.Composer) {
if c.Sent() && store != nil {
store.Forwarded([]models.UID{msg.Uid}, true, nil)
}
os.RemoveAll(tmpDir)
})
})
} else {
if f.Template == "" {
f.Template = config.Templates.Forwards
}
var fetchBodyPart func([]int, func(io.Reader))
if isMsgViewer {
fetchBodyPart = mv.MessageView().FetchBodyPart
} else {
fetchBodyPart = func(part []int, cb func(io.Reader)) {
store.FetchBodyPart(msg.Uid, part, cb)
}
}
if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer {
return fmt.Errorf("message is encrypted. " +
"can only forward from the message viewer")
}
part := getMessagePart(msg, widget)
if part == nil {
part = lib.FindFirstNonMultipart(msg.BodyStructure, nil)
// if it's still nil here, we don't have a multipart msg, that's fine
}
err = addMimeType(msg, part, &original)
if err != nil {
return err
}
fetchBodyPart(part, func(reader io.Reader) {
buf := new(bytes.Buffer)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
buf.WriteString(scanner.Text() + "\n")
}
original.Text = buf.String()
// create composer
composer, err := addTab()
if err != nil {
return
}
composer.OnClose(func(c *app.Composer) {
if c.Sent() && store != nil {
store.Forwarded([]models.UID{msg.Uid}, true, nil)
}
})
// add attachments
if f.AttachAll {
var mu sync.Mutex
parts := lib.FindAllNonMultipart(msg.BodyStructure, nil, nil)
for _, p := range parts {
if lib.EqualParts(p, part) {
continue
}
bs, err := msg.BodyStructure.PartAtIndex(p)
if err != nil {
log.Errorf("cannot get PartAtIndex %v: %v", p, err)
continue
}
fetchBodyPart(p, func(reader io.Reader) {
mime := bs.FullMIMEType()
params := lib.SetUtf8Charset(bs.Params)
name := bs.FileName()
if name == "" {
name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64())
}
mu.Lock()
err := composer.AddPartAttachment(name, mime, params, reader)
mu.Unlock()
if err != nil {
log.Errorf(err.Error())
app.PushError(err.Error())
}
})
}
}
})
}
return nil
}
+180
View File
@@ -0,0 +1,180 @@
package msg
import (
"errors"
"fmt"
"io"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/calendar"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
)
type invite struct {
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(invite{})
}
func (invite) Description() string {
return "Accept or decline a meeting invitation."
}
func (invite) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (invite) Aliases() []string {
return []string{"accept", "accept-tentative", "decline"}
}
func (i invite) Execute(args []string) error {
acct := app.SelectedAccount()
if acct == nil {
return errors.New("no account selected")
}
store := acct.Store()
if store == nil {
return errors.New("cannot perform action: messages still loading")
}
msg, err := acct.SelectedMessage()
if err != nil {
return err
}
part := lib.FindCalendartext(msg.BodyStructure, nil)
if part == nil {
return fmt.Errorf("no invitation found (missing text/calendar)")
}
editHeaders := (config.Compose.EditHeaders || i.Edit) && !i.NoEdit
subject := trimLocalizedRe(msg.Envelope.Subject, acct.AccountConfig().LocalizedRe)
switch args[0] {
case "accept":
subject = "Accepted: " + subject
case "accept-tentative":
subject = "Tentatively Accepted: " + subject
case "decline":
subject = "Declined: " + subject
default:
return fmt.Errorf("no participation status defined")
}
from := chooseFromAddr(acct.AccountConfig(), msg)
var to []*mail.Address
if len(msg.Envelope.ReplyTo) != 0 {
to = msg.Envelope.ReplyTo
} else {
to = msg.Envelope.From
}
if !config.Compose.ReplyToSelf {
for i, v := range to {
if v.Address == from.Address {
to = append(to[:i], to[i+1:]...)
break
}
}
if len(to) == 0 {
to = msg.Envelope.To
}
}
recSet := newAddrSet() // used for de-duping
recSet.AddList(to)
h := &mail.Header{}
h.SetAddressList("from", []*mail.Address{from})
h.SetSubject(subject)
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
err = setReferencesHeader(h, msg.RFC822Headers)
if err != nil {
app.PushError(fmt.Sprintf("could not set references: %v", err))
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
handleInvite := func(reader io.Reader) (*calendar.Reply, error) {
cr, err := calendar.CreateReply(reader, from, args[0])
if err != nil {
return nil, err
}
for _, org := range cr.Organizers {
organizer, err := mail.ParseAddress(org)
if err != nil {
continue
}
if !recSet.Contains(organizer) {
to = append(to, organizer)
}
}
h.SetAddressList("to", to)
return cr, nil
}
addTab := func(cr *calendar.Reply) error {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
"", h, &original, cr.PlainText)
if err != nil {
app.PushError("Error: " + err.Error())
return err
}
err = composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText)
if err != nil {
return fmt.Errorf("failed to write invitation: %w", err)
}
if i.SkipEditor {
composer.Terminal().Close()
} else {
composer.FocusTerminal()
}
composer.Tab = app.NewTab(composer, subject)
composer.OnClose(func(c *app.Composer) {
switch {
case c.Sent() && c.Archive() != "":
store.Answered([]models.UID{msg.Uid}, true, nil)
err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
if err != nil {
app.PushStatus("Archive failed", 10*time.Second)
}
case c.Sent():
store.Answered([]models.UID{msg.Uid}, true, nil)
}
})
return nil
}
store.FetchBodyPart(msg.Uid, part, func(reader io.Reader) {
if cr, err := handleInvite(reader); err != nil {
app.PushError(err.Error())
return
} else {
err := addTab(cr)
if err != nil {
log.Warnf("failed to add tab: %v", err)
}
}
})
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package msg
import (
"fmt"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/models"
)
type Mark struct {
All bool `opt:"-a" aliases:"mark,unmark" desc:"Mark all messages in current folder."`
Toggle bool `opt:"-t" aliases:"mark,unmark" desc:"Toggle the marked state."`
Visual bool `opt:"-v" aliases:"mark,unmark" desc:"Enter / leave visual mark mode."`
VisualClear bool `opt:"-V" aliases:"mark,unmark" desc:"Same as -v but does not clear existing selection."`
Thread bool `opt:"-T" aliases:"mark,unmark" desc:"Mark all messages from the selected thread."`
}
func init() {
commands.Register(Mark{})
}
func (Mark) Description() string {
return "Mark, unmark or remark messages."
}
func (Mark) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Mark) Aliases() []string {
return []string{"mark", "unmark", "remark"}
}
func (m Mark) Execute(args []string) error {
h := newHelper()
OnSelectedMessage := func(fn func(models.UID)) error {
if fn == nil {
return fmt.Errorf("no operation selected")
}
selected, err := h.msgProvider.SelectedMessage()
if err != nil {
return err
}
fn(selected.Uid)
return nil
}
store, err := h.store()
if err != nil {
return err
}
marker := store.Marker()
if m.Thread && m.All {
return fmt.Errorf("-a and -T are mutually exclusive")
}
if m.Thread && (m.Visual || m.VisualClear) {
return fmt.Errorf("-v and -T are mutually exclusive")
}
if m.Visual && m.All {
return fmt.Errorf("-a and -v are mutually exclusive")
}
switch args[0] {
case "mark":
var modFunc func(models.UID)
if m.Toggle {
modFunc = marker.ToggleMark
} else {
modFunc = marker.Mark
}
switch {
case m.All:
uids := store.Uids()
for _, uid := range uids {
modFunc(uid)
}
return nil
case m.Visual || m.VisualClear:
marker.ToggleVisualMark(m.VisualClear)
return nil
default:
if m.Thread {
threadPtr, err := store.SelectedThread()
if err != nil {
return err
}
for _, uid := range threadPtr.Root().Uids() {
modFunc(uid)
}
} else {
return OnSelectedMessage(modFunc)
}
return nil
}
case "unmark":
if m.Visual || m.VisualClear {
return fmt.Errorf("visual mode not supported for this command")
}
switch {
case m.All && m.Toggle:
uids := store.Uids()
for _, uid := range uids {
marker.ToggleMark(uid)
}
return nil
case m.All && !m.Toggle:
marker.ClearVisualMark()
return nil
default:
if m.Thread {
threadPtr, err := store.SelectedThread()
if err != nil {
return err
}
for _, uid := range threadPtr.Root().Uids() {
marker.Unmark(uid)
}
} else {
return OnSelectedMessage(marker.Unmark)
}
return nil
}
case "remark":
marker.Remark()
return nil
}
return nil // never reached
}
+70
View File
@@ -0,0 +1,70 @@
package msg
import (
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type ModifyLabels struct {
Labels []string `opt:"..." metavar:"[+-]<label>" complete:"CompleteLabels" desc:"Message label."`
}
func init() {
commands.Register(ModifyLabels{})
}
func (ModifyLabels) Description() string {
return "Modify message labels."
}
func (ModifyLabels) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (ModifyLabels) Aliases() []string {
return []string{"modify-labels", "tag"}
}
func (*ModifyLabels) CompleteLabels(arg string) []string {
return commands.GetLabels(arg)
}
func (m ModifyLabels) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
var add, remove []string
for _, l := range m.Labels {
switch l[0] {
case '+':
add = append(add, l[1:])
case '-':
remove = append(remove, l[1:])
default:
// if no operand is given assume add
add = append(add, l)
}
}
store.ModifyLabels(uids, add, remove, func(
msg types.WorkerMessage,
) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("labels updated", 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
})
return nil
}
+267
View File
@@ -0,0 +1,267 @@
package msg
import (
"bytes"
"fmt"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/marker"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Move struct {
CreateFolders bool `opt:"-p" desc:"Create missing folders if required."`
Account string `opt:"-a" complete:"CompleteAccount" desc:"Move to specified account."`
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
}
func init() {
commands.Register(Move{})
}
func (Move) Description() string {
return "Move the selected message(s) to the specified folder."
}
func (Move) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Move) Aliases() []string {
return []string{"mv", "move"}
}
func (m *Move) ParseMFS(arg string) error {
if arg != "" {
mfs, ok := types.StrToStrategy[arg]
if !ok {
return fmt.Errorf("invalid multi-file strategy %s", arg)
}
m.MultiFileStrategy = &mfs
}
return nil
}
func (*Move) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (m *Move) CompleteFolder(arg string) []string {
var acct *app.AccountView
if len(m.Account) > 0 {
acct, _ = app.Account(m.Account)
} else {
acct = app.SelectedAccount()
}
if acct == nil {
return nil
}
return commands.FilterList(acct.Directories().List(), arg, nil)
}
func (Move) CompleteMFS(arg string) []string {
return commands.FilterList(types.StrategyStrs(), arg, nil)
}
func (m Move) Execute(args []string) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
uids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
next := findNextNonDeleted(uids, store)
marker := store.Marker()
marker.ClearVisualMark()
if len(m.Account) == 0 {
store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy,
func(msg types.WorkerMessage) {
m.CallBack(msg, acct, uids, next, marker, false)
})
return nil
}
destAcct, err := app.Account(m.Account)
if err != nil {
return err
}
destStore := destAcct.Store()
if destStore == nil {
app.PushError(fmt.Sprintf("No message store in %s", m.Account))
return nil
}
var messages []*types.FullMessage
fetchDone := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
messages = append(messages, fm)
if len(messages) == len(uids) {
fetchDone <- true
}
})
// Since this operation can take some time with some backends
// (e.g. IMAP), provide some feedback to inform the user that
// something is happening
app.PushStatus("Moving messages...", 10*time.Second)
var appended []models.UID
var timeout bool
go func() {
defer log.PanicHandler()
select {
case <-fetchDone:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
AppendLoop:
for _, fm := range messages {
done := make(chan bool, 1)
uid := fm.Content.Uid
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(fm.Content.Reader)
if err != nil {
log.Errorf("could not get reader for uid %d", uid)
break
}
destStore.Append(
m.Folder,
models.SeenFlag,
time.Now(),
buf,
buf.Len(),
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
appended = append(appended, uid)
done <- true
case *types.Error:
log.Errorf("AppendMessage failed: %v", msg.Error)
done <- false
}
},
)
select {
case ok := <-done:
if !ok {
break AppendLoop
}
case <-time.After(30 * time.Second):
log.Warnf("timed-out: appended %d of %d", len(appended), len(messages))
timeout = true
break AppendLoop
}
}
if len(appended) > 0 {
mfs := types.Refuse
store.Delete(appended, &mfs, func(msg types.WorkerMessage) {
m.CallBack(msg, acct, appended, next, marker, timeout)
})
}
}()
return nil
}
func (m Move) CallBack(
msg types.WorkerMessage,
acct *app.AccountView,
uids []models.UID,
next *models.MessageInfo,
marker marker.Marker,
timeout bool,
) {
switch msg := msg.(type) {
case *types.Done:
var s string
if len(uids) > 1 {
s = "%d messages moved to %s"
} else {
s = "%d message moved to %s"
}
dest := m.Folder
if len(m.Account) > 0 {
dest = fmt.Sprintf("%s in %s", m.Folder, m.Account)
}
if timeout {
s = "timed-out: only " + s
app.PushError(fmt.Sprintf(s, len(uids), dest))
} else {
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
}
if store := acct.Store(); store != nil {
handleDone(acct, next, store)
}
case *types.Error:
app.PushError(msg.Error.Error())
marker.Remark()
case *types.Unsupported:
marker.Remark()
app.PushError("error, unsupported for this worker")
}
}
func handleDone(
acct *app.AccountView,
next *models.MessageInfo,
store *lib.MessageStore,
) {
h := newHelper()
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
switch {
case isMsgView && !config.Ui.NextMessageOnDelete:
app.RemoveTab(h.msgProvider, true)
case isMsgView:
if next == nil {
app.RemoveTab(h.msgProvider, true)
acct.Messages().Select(-1)
ui.Invalidate()
return
}
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
store, app.CryptoProvider(), app.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
nextMv, err := app.NewMessageViewer(acct, view)
if err != nil {
app.PushError(err.Error())
return
}
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
})
default:
if next == nil {
// We moved the last message, select the new last message
// instead of the first message
acct.Messages().Select(-1)
}
}
}
+302
View File
@@ -0,0 +1,302 @@
package msg
import (
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"regexp"
"sort"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Pipe struct {
Background bool `opt:"-b" desc:"Run the command in the background."`
Silent bool `opt:"-s" desc:"Silently close the terminal tab after the command exits."`
Full bool `opt:"-m" desc:"Pipe the full message."`
Decrypt bool `opt:"-d" desc:"Decrypt the full message before piping."`
Part bool `opt:"-p" desc:"Only pipe the selected message part."`
Command string `opt:"..."`
}
func init() {
commands.Register(Pipe{})
}
func (Pipe) Description() string {
return "Pipe the selected message(s) into the given shell command."
}
func (Pipe) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Pipe) Aliases() []string {
return []string{"pipe"}
}
func (p Pipe) Execute(args []string) error {
return p.Run(nil)
}
func (p Pipe) Run(cb func()) error {
if p.Decrypt {
// Decrypt implies fetching the full message
p.Full = true
}
if p.Full && p.Part {
return errors.New("-m and -p are mutually exclusive")
}
name, _, _ := strings.Cut(p.Command, " ")
provider := app.SelectedTabContent().(app.ProvidesMessage)
if !p.Full && !p.Part {
if _, ok := provider.(*app.MessageViewer); ok {
p.Part = true
} else if _, ok := provider.(*app.AccountView); ok {
p.Full = true
} else {
return errors.New(
"Neither -m nor -p specified and cannot infer default")
}
}
doTerm := func(reader io.Reader, name string) {
cmd := []string{"sh", "-c", p.Command}
term, err := commands.QuickTerm(cmd, reader, p.Silent)
if err != nil {
app.PushError(err.Error())
return
}
if cb != nil {
last := term.OnClose
term.OnClose = func(err error) {
if last != nil {
last(err)
}
cb()
}
}
app.NewTab(term, name)
}
doExec := func(reader io.Reader) {
ecmd := exec.Command("sh", "-c", p.Command)
pipe, err := ecmd.StdinPipe()
if err != nil {
return
}
go func() {
defer log.PanicHandler()
defer pipe.Close()
_, err := io.Copy(pipe, reader)
if err != nil {
log.Errorf("failed to send data to pipe: %v", err)
}
}()
err = ecmd.Run()
if err != nil {
app.PushError(err.Error())
} else {
if ecmd.ProcessState.ExitCode() != 0 {
app.PushError(fmt.Sprintf(
"%s: completed with status %d", name,
ecmd.ProcessState.ExitCode()))
} else {
app.PushStatus(fmt.Sprintf(
"%s: completed with status %d", name,
ecmd.ProcessState.ExitCode()), 10*time.Second)
}
}
if cb != nil {
cb()
}
}
app.PushStatus("Fetching messages ...", 10*time.Second)
if p.Full {
var uids []models.UID
var title string
h := newHelper()
store, err := h.store()
if err != nil {
if mv, ok := provider.(*app.MessageViewer); ok {
mv.MessageView().FetchFull(func(reader io.Reader) {
if p.Background {
doExec(reader)
} else {
doTerm(reader,
fmt.Sprintf("%s <%s",
name, title))
}
})
return nil
}
return err
}
uids, err = h.markedOrSelectedUids()
if err != nil {
return err
}
if len(uids) == 1 {
info := store.Messages[uids[0]]
if info != nil {
envelope := info.Envelope
if envelope != nil {
title = envelope.Subject
}
}
}
if title == "" {
title = fmt.Sprintf("%d messages", len(uids))
}
var messages []*types.FullMessage
var errors []error
done := make(chan bool, 1)
store.FetchFull(uids, func(fm *types.FullMessage) {
if p.Decrypt {
info := store.Messages[fm.Content.Uid]
if info == nil {
goto addMessage
}
var buf bytes.Buffer
cleartext, err := cryptoutil.Cleartext(
io.TeeReader(fm.Content.Reader, &buf),
info.RFC822Headers.Copy(),
)
if err != nil {
log.Warnf("continue encrypted: %v", err)
fm.Content.Reader = bytes.NewReader(buf.Bytes())
} else {
fm.Content.Reader = bytes.NewReader(cleartext)
}
}
addMessage:
info := store.Messages[fm.Content.Uid]
switch {
case info != nil && info.Envelope != nil:
messages = append(messages, fm)
case info != nil && info.Error != nil:
app.PushError(info.Error.Error())
errors = append(errors, info.Error)
default:
err := fmt.Errorf("%v nil info", fm.Content.Uid)
app.PushError(err.Error())
errors = append(errors, err)
}
if len(messages)+len(errors) == len(uids) {
done <- true
}
})
go func() {
defer log.PanicHandler()
select {
case <-done:
break
case <-time.After(30 * time.Second):
// TODO: find a better way to determine if store.FetchFull()
// has finished with some errors.
app.PushError("Failed to fetch all messages")
if len(messages) == 0 {
return
}
}
is_git_patches := false
for _, msg := range messages {
info := store.Messages[msg.Content.Uid]
if info == nil || info.Envelope == nil {
continue
}
if patchSeriesRe.MatchString(info.Envelope.Subject) {
is_git_patches = true
break
}
}
if is_git_patches {
// Sort all messages by increasing Message-Id header.
// This will ensure that patch series are applied in order.
sort.Slice(messages, func(i, j int) bool {
infoi := store.Messages[messages[i].Content.Uid]
infoj := store.Messages[messages[j].Content.Uid]
if infoi == nil || infoi.Envelope == nil ||
infoj == nil || infoj.Envelope == nil {
return false
}
return infoi.Envelope.Subject < infoj.Envelope.Subject
})
}
reader := newMessagesReader(messages, len(messages) > 1)
if p.Background {
doExec(reader)
} else {
doTerm(reader, fmt.Sprintf("%s <%s", name, title))
}
}()
} else if p.Part {
mv, ok := provider.(*app.MessageViewer)
if !ok {
return fmt.Errorf("can only pipe message part from a message view")
}
part := provider.SelectedMessagePart()
if part == nil {
return fmt.Errorf("could not fetch message part")
}
mv.MessageView().FetchBodyPart(part.Index, func(reader io.Reader) {
if p.Background {
doExec(reader)
} else {
name := fmt.Sprintf("%s <%s/[%d]",
name, part.Msg.Envelope.Subject, part.Index)
doTerm(reader, name)
}
})
}
if store := provider.Store(); store != nil {
store.Marker().ClearVisualMark()
}
return nil
}
func newMessagesReader(messages []*types.FullMessage, useMbox bool) io.Reader {
pr, pw := io.Pipe()
go func() {
defer log.PanicHandler()
defer pw.Close()
for _, msg := range messages {
var err error
if useMbox {
err = mboxer.Write(pw, msg.Content.Reader, "", time.Now())
} else {
_, err = io.Copy(pw, msg.Content.Reader)
}
if err != nil {
log.Warnf("failed to write data: %v", err)
}
}
}()
return pr
}
var patchSeriesRe = regexp.MustCompile(
`^.*\[(RFC )?PATCH( [^\]]+)? \d+/\d+] .+$`,
)
+162
View File
@@ -0,0 +1,162 @@
package msg
import (
"fmt"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type FlagMsg struct {
Toggle bool `opt:"-t" desc:"Toggle between set and unset."`
Answered bool `opt:"-a" aliases:"flag,unflag" desc:"Set/unset the answered flag."`
Forwarded bool `opt:"-f" aliases:"flag,unflag" desc:"Set/unset the forwarded flag."`
Flag models.Flags `opt:"-x" aliases:"flag,unflag" action:"ParseFlag" complete:"CompleteFlag" desc:"Flag name."`
FlagName string
}
func init() {
commands.Register(FlagMsg{})
}
func (FlagMsg) Description() string {
return "Set or unset a flag on the marked or selected messages."
}
func (FlagMsg) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (FlagMsg) Aliases() []string {
return []string{"flag", "unflag", "read", "unread"}
}
func (f *FlagMsg) ParseFlag(arg string) error {
switch strings.ToLower(arg) {
case "seen":
f.Flag = models.SeenFlag
f.FlagName = "seen"
case "answered":
f.Flag = models.AnsweredFlag
f.FlagName = "answered"
case "forwarded":
f.Flag = models.ForwardedFlag
f.FlagName = "forwarded"
case "flagged":
f.Flag = models.FlaggedFlag
f.FlagName = "flagged"
case "draft":
f.Flag = models.DraftFlag
f.FlagName = "draft"
default:
return fmt.Errorf("Unknown flag %q", arg)
}
return nil
}
var validFlags = []string{"seen", "answered", "forwarded", "flagged", "draft"}
func (*FlagMsg) CompleteFlag(arg string) []string {
return commands.FilterList(validFlags, arg, nil)
}
// If this was called as 'flag' or 'unflag', without the toggle (-t)
// option, then it will flag the corresponding messages with the given
// flag. If the toggle option was given, it will individually toggle
// the given flag for the corresponding messages.
//
// If this was called as 'read' or 'unread', it has the same effect as
// 'flag' or 'unflag', respectively, but the 'Seen' flag is affected.
func (f FlagMsg) Execute(args []string) error {
// User-readable name for the action being performed
var actionName string
switch args[0] {
case "read", "unread":
f.Flag = models.SeenFlag
f.FlagName = "seen"
case "flag", "unflag":
if f.Answered {
f.Flag = models.AnsweredFlag
f.FlagName = "answered"
}
if f.Forwarded {
f.Flag = models.ForwardedFlag
f.FlagName = "forwarded"
}
if f.Flag == 0 {
f.Flag = models.FlaggedFlag
f.FlagName = "flagged"
}
}
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
// UIDs of messages to enable or disable the flag for.
var toEnable []models.UID
var toDisable []models.UID
if f.Toggle {
// If toggling, split messages into those that need to
// be enabled / disabled.
msgs, err := h.messages()
if err != nil {
return err
}
for _, m := range msgs {
if m.Flags.Has(f.Flag) {
toDisable = append(toDisable, m.Uid)
} else {
toEnable = append(toEnable, m.Uid)
}
}
actionName = "Toggling"
} else {
msgUids, err := h.markedOrSelectedUids()
if err != nil {
return err
}
switch args[0] {
case "read", "flag":
toEnable = msgUids
actionName = "Setting"
default:
toDisable = msgUids
actionName = "Unsetting"
}
}
status := fmt.Sprintf("%s flag %q successful", actionName, f.FlagName)
if len(toEnable) != 0 {
store.Flag(toEnable, f.Flag, true, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus(status, 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
})
}
if len(toDisable) != 0 {
store.Flag(toDisable, f.Flag, false, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus(status, 10*time.Second)
store.Marker().ClearVisualMark()
case *types.Error:
app.PushError(msg.Error.Error())
}
})
}
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package msg
import (
"fmt"
"io"
"math/rand"
"sync"
"time"
_ "github.com/emersion/go-message/charset"
"github.com/pkg/errors"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type Recall struct {
Force bool `opt:"-f" desc:"Force recall if not in postpone directory."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(Recall{})
}
func (Recall) Description() string {
return "Open a postponed message for re-editing."
}
func (Recall) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (Recall) Aliases() []string {
return []string{"recall"}
}
func (r Recall) Execute(args []string) error {
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
store := widget.Store()
if store == nil {
return errors.New("Cannot perform action. Messages still loading")
}
msgInfo, err := widget.SelectedMessage()
if err != nil {
return errors.Wrap(err, "Recall failed")
}
if acct.SelectedDirectory() != acct.AccountConfig().Postpone &&
!msgInfo.Flags.Has(models.DraftFlag) && !r.Force {
return errors.New("Use -f to recall non-draft messages from outside the " +
acct.AccountConfig().Postpone + " directory.")
}
log.Debugf("Recalling message <%s>", msgInfo.Envelope.MessageId)
addTab := func(composer *app.Composer) {
subject := msgInfo.Envelope.Subject
if subject == "" {
subject = "Recalled email"
}
composer.Tab = app.NewTab(composer, subject)
composer.OnClose(func(composer *app.Composer) {
uids := []models.UID{msgInfo.Uid}
deleteMessage := func() {
store.Delete(
uids,
nil,
func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Done:
app.PushStatus("Recalled message deleted", 10*time.Second)
case *types.Error:
app.PushError(msg.Error.Error())
}
},
)
}
if composer.Sent() || composer.Postponed() {
deleteMessage()
}
})
}
lib.NewMessageStoreView(msgInfo, acct.UiConfig().AutoMarkRead,
store, app.CryptoProvider(), app.DecryptKeys,
func(msg lib.MessageView, err error) {
if err != nil {
app.PushError(err.Error())
return
}
var path []int
if len(msg.BodyStructure().Parts) != 0 {
path = lib.FindPlaintext(msg.BodyStructure(), path)
}
msg.FetchBodyPart(path, func(reader io.Reader) {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
"", msgInfo.RFC822Headers, nil, reader)
if err != nil {
app.PushError(err.Error())
return
}
if md := msg.MessageDetails(); md != nil {
if md.IsEncrypted {
composer.SetEncrypt(md.IsEncrypted)
}
if md.IsSigned {
err = composer.SetSign(md.IsSigned)
if err != nil {
log.Warnf("failed to set signed state: %v", err)
}
}
}
// add attachments if present
var mu sync.Mutex
parts := lib.FindAllNonMultipart(msg.BodyStructure(), nil, nil)
for _, p := range parts {
if lib.EqualParts(p, path) {
continue
}
bs, err := msg.BodyStructure().PartAtIndex(p)
if err != nil {
log.Warnf("cannot get PartAtIndex %v: %v", p, err)
continue
}
msg.FetchBodyPart(p, func(reader io.Reader) {
mime := bs.FullMIMEType()
params := lib.SetUtf8Charset(bs.Params)
name, ok := params["name"]
if !ok {
name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64())
}
mu.Lock()
err := composer.AddPartAttachment(name, mime, params, reader)
mu.Unlock()
if err != nil {
log.Errorf(err.Error())
app.PushError(err.Error())
}
})
}
if r.Force {
composer.SetRecalledFrom(acct.SelectedDirectory())
}
if r.SkipEditor {
composer.Terminal().Close()
} else {
// focus the terminal since the header fields are likely already done
composer.FocusTerminal()
}
addTab(composer)
})
})
return nil
}
+354
View File
@@ -0,0 +1,354 @@
package msg
import (
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/commands/account"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/format"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/models"
"github.com/danwakefield/fnmatch"
"github.com/emersion/go-message/mail"
)
type reply struct {
All bool `opt:"-a" desc:"Reply to all recipients."`
Close bool `opt:"-c" desc:"Close the view tab when replying."`
From bool `opt:"-f" desc:"Reply to all addresses in From and Reply-To headers."`
Quote bool `opt:"-q" desc:"Alias of -T quoted-reply."`
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
Account string `opt:"-A" complete:"CompleteAccount" desc:"Reply with the specified account."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(reply{})
}
func (reply) Description() string {
return "Open the composer to reply to the selected message."
}
func (reply) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (reply) Aliases() []string {
return []string{"reply"}
}
func (*reply) CompleteTemplate(arg string) []string {
return commands.GetTemplates(arg)
}
func (*reply) CompleteAccount(arg string) []string {
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
}
func (r reply) Execute(args []string) error {
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
var acct *app.AccountView
var err error
if r.Account == "" {
acct = widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
} else {
acct, err = app.Account(r.Account)
if err != nil {
return err
}
}
conf := acct.AccountConfig()
msg, err := widget.SelectedMessage()
if err != nil {
return err
}
from := chooseFromAddr(conf, msg)
var (
to []*mail.Address
cc []*mail.Address
)
recSet := newAddrSet() // used for de-duping
dedupe := func(addrs []*mail.Address) []*mail.Address {
deduped := make([]*mail.Address, 0, len(addrs))
for _, addr := range addrs {
if recSet.Contains(addr) {
continue
}
recSet.Add(addr)
deduped = append(deduped, addr)
}
return deduped
}
if !config.Compose.ReplyToSelf {
recSet.Add(from)
}
switch {
case len(msg.Envelope.ReplyTo) != 0:
to = dedupe(msg.Envelope.ReplyTo)
case len(msg.Envelope.From) != 0:
to = dedupe(msg.Envelope.From)
default:
to = dedupe(msg.Envelope.Sender)
}
if r.From {
to = append(to, dedupe(msg.Envelope.From)...)
}
if !config.Compose.ReplyToSelf && len(to) == 0 {
recSet = newAddrSet()
to = dedupe(msg.Envelope.To)
}
if r.All {
// order matters, due to the deduping
// in order of importance, first parse the To, then the Cc header
// we add our from address, so that we don't self address ourselves
recSet.Add(from)
to = append(to, dedupe(msg.Envelope.To)...)
cc = append(cc, dedupe(msg.Envelope.Cc)...)
cc = append(cc, dedupe(msg.Envelope.Sender)...)
}
subject := "Re: " + trimLocalizedRe(msg.Envelope.Subject, conf.LocalizedRe)
h := &mail.Header{}
h.SetAddressList("to", to)
h.SetAddressList("cc", cc)
h.SetAddressList("from", []*mail.Address{from})
h.SetSubject(subject)
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
err = setReferencesHeader(h, msg.RFC822Headers)
if err != nil {
app.PushError(fmt.Sprintf("could not set references: %v", err))
}
original := models.OriginalMail{
From: format.FormatAddresses(msg.Envelope.From),
Date: msg.Envelope.Date,
RFC822Headers: msg.RFC822Headers,
}
mv, isMsgViewer := app.SelectedTabContent().(*app.MessageViewer)
store := widget.Store()
noStore := store == nil
switch {
case noStore && isMsgViewer:
app.PushWarning("No message store found: answered flag cannot be set")
case noStore:
return errors.New("Cannot perform action. Messages still loading")
default:
original.Folder = store.Name
}
addTab := func() error {
composer, err := app.NewComposer(acct,
acct.AccountConfig(), acct.Worker(), editHeaders,
r.Template, h, &original, nil)
if err != nil {
app.PushError("Error: " + err.Error())
return err
}
if mv != nil && r.Close {
app.RemoveTab(mv, true)
}
if r.SkipEditor {
composer.Terminal().Close()
} else if args[0] == "reply" {
composer.FocusTerminal()
}
composer.Tab = app.NewTab(composer, subject)
composer.OnClose(func(c *app.Composer) {
switch {
case c.Sent() && c.Archive() != "" && !noStore:
store.Answered([]models.UID{msg.Uid}, true, nil)
err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
if err != nil {
app.PushStatus("Archive failed", 10*time.Second)
}
case c.Sent() && !noStore:
store.Answered([]models.UID{msg.Uid}, true, nil)
case mv != nil && r.Close:
view := account.ViewMessage{Peek: true}
//nolint:errcheck // who cares?
view.Execute([]string{"view", "-p"})
}
})
return nil
}
if r.Quote && r.Template == "" {
r.Template = config.Templates.QuotedReply
}
if r.Template != "" {
var fetchBodyPart func([]int, func(io.Reader))
if isMsgViewer {
fetchBodyPart = mv.MessageView().FetchBodyPart
} else {
fetchBodyPart = func(part []int, cb func(io.Reader)) {
store.FetchBodyPart(msg.Uid, part, cb)
}
}
if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer {
return fmt.Errorf("message is encrypted. " +
"can only include reply from the message viewer")
}
part := getMessagePart(msg, widget)
if part == nil {
// mkey... let's get the first thing that isn't a container
// if that's still nil it's either not a multipart msg (ok) or
// broken (containers only)
part = lib.FindFirstNonMultipart(msg.BodyStructure, nil)
}
err = addMimeType(msg, part, &original)
if err != nil {
return err
}
fetchBodyPart(part, func(reader io.Reader) {
data, err := io.ReadAll(reader)
if err != nil {
log.Warnf("failed to read bodypart: %v", err)
}
original.Text = string(data)
err = addTab()
if err != nil {
log.Warnf("failed to add tab: %v", err)
}
})
return nil
} else {
r.Template = config.Templates.NewMessage
return addTab()
}
}
func chooseFromAddr(conf *config.AccountConfig, msg *models.MessageInfo) *mail.Address {
if len(conf.Aliases) == 0 {
return conf.From
}
rec := newAddrSet()
rec.AddList(msg.Envelope.From)
rec.AddList(msg.Envelope.To)
rec.AddList(msg.Envelope.Cc)
// test the from first, it has priority over any present alias
if rec.Contains(conf.From) {
// do nothing
} else {
for _, a := range conf.Aliases {
if match := rec.FindMatch(a); match != "" {
return &mail.Address{Name: a.Name, Address: match}
}
}
}
return conf.From
}
type addrSet map[string]struct{}
func newAddrSet() addrSet {
s := make(map[string]struct{})
return addrSet(s)
}
func (s addrSet) Add(a *mail.Address) {
s[a.Address] = struct{}{}
}
func (s addrSet) AddList(al []*mail.Address) {
for _, a := range al {
s[a.Address] = struct{}{}
}
}
func (s addrSet) Contains(a *mail.Address) bool {
_, ok := s[a.Address]
return ok
}
func (s addrSet) FindMatch(a *mail.Address) string {
for addr := range s {
if fnmatch.Match(a.Address, addr, 0) {
return addr
}
}
return ""
}
// setReferencesHeader adds the references header to target based on parent
// according to RFC2822
func setReferencesHeader(target, parent *mail.Header) error {
refs := parse.MsgIDList(parent, "references")
if len(refs) == 0 {
// according to the RFC we need to fall back to in-reply-to only if
// References is not set
refs = parse.MsgIDList(parent, "in-reply-to")
}
msgID, err := parent.MessageID()
if err != nil {
return err
}
refs = append(refs, msgID)
target.SetMsgIDList("references", refs)
return nil
}
// addMimeType adds the proper mime type of the part to the originalMail struct
func addMimeType(msg *models.MessageInfo, part []int,
orig *models.OriginalMail,
) error {
// caution, :forward uses the code as well, keep that in mind when modifying
bs, err := msg.BodyStructure.PartAtIndex(part)
if err != nil {
return err
}
orig.MIMEType = bs.FullMIMEType()
return nil
}
// trimLocalizedRe removes known localizations of Re: commonly used by Outlook.
func trimLocalizedRe(subject string, localizedRe *regexp.Regexp) string {
return strings.TrimPrefix(subject, localizedRe.FindString(subject))
}
+35
View File
@@ -0,0 +1,35 @@
package msg
import (
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type ToggleThreadContext struct{}
func init() {
commands.Register(ToggleThreadContext{})
}
func (ToggleThreadContext) Description() string {
return "Show/hide message thread context."
}
func (ToggleThreadContext) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (ToggleThreadContext) Aliases() []string {
return []string{"toggle-thread-context"}
}
func (ToggleThreadContext) Execute(args []string) error {
h := newHelper()
store, err := h.store()
if err != nil {
return err
}
store.ToggleThreadContext()
ui.Invalidate()
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package msg
import (
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type ToggleThreads struct{}
func init() {
commands.Register(ToggleThreads{})
}
func (ToggleThreads) Description() string {
return "Toggle between message threading and the normal message list."
}
func (ToggleThreads) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
func (ToggleThreads) Aliases() []string {
return []string{"toggle-threads"}
}
func (ToggleThreads) Execute(args []string) error {
h := newHelper()
acct, err := h.account()
if err != nil {
return err
}
store, err := h.store()
if err != nil {
return err
}
store.SetThreadedView(!store.ThreadedView())
acct.SetStatus(state.Threading(store.ThreadedView()))
ui.Invalidate()
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package msg
import (
"bufio"
"errors"
"fmt"
"net/url"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"github.com/emersion/go-message/mail"
)
// Unsubscribe helps people unsubscribe from mailing lists by way of the
// List-Unsubscribe header.
type Unsubscribe struct {
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
}
func init() {
commands.Register(Unsubscribe{})
}
func (Unsubscribe) Description() string {
return "Attempt to automatically unsubscribe from mailing lists."
}
func (Unsubscribe) Context() commands.CommandContext {
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
}
// Aliases returns a list of aliases for the :unsubscribe command
func (Unsubscribe) Aliases() []string {
return []string{"unsubscribe"}
}
// Execute runs the Unsubscribe command
func (u Unsubscribe) Execute(args []string) error {
editHeaders := (config.Compose.EditHeaders || u.Edit) && !u.NoEdit
widget := app.SelectedTabContent().(app.ProvidesMessage)
msg, err := widget.SelectedMessage()
if err != nil {
return err
}
headers := msg.RFC822Headers
if !headers.Has("list-unsubscribe") {
return errors.New("No List-Unsubscribe header found")
}
text, err := headers.Text("list-unsubscribe")
if err != nil {
return err
}
methods := parseUnsubscribeMethods(text)
if len(methods) == 0 {
return fmt.Errorf("no methods found to unsubscribe")
}
log.Debugf("unsubscribe: found %d methods", len(methods))
unsubscribe := func(method *url.URL) {
log.Debugf("unsubscribe: trying to unsubscribe using %s", method.Scheme)
var err error
switch strings.ToLower(method.Scheme) {
case "mailto":
err = unsubscribeMailto(method, editHeaders, u.SkipEditor)
case "http", "https":
err = unsubscribeHTTP(method)
default:
err = fmt.Errorf("unsubscribe: skipping unrecognized scheme: %s", method.Scheme)
}
if err != nil {
app.PushError(err.Error())
}
}
var title string = "Select method to unsubscribe"
if msg != nil && msg.Envelope != nil && len(msg.Envelope.From) > 0 {
title = fmt.Sprintf("%s from %s", title, msg.Envelope.From[0])
}
options := make([]string, len(methods))
for i, method := range methods {
options[i] = method.Scheme
}
dialog := app.NewSelectorDialog(
title,
"Press <Enter> to confirm or <ESC> to cancel",
options, 0, app.SelectedAccountUiConfig(),
func(option string, err error) {
app.CloseDialog()
if err != nil {
if errors.Is(err, app.ErrNoOptionSelected) {
app.PushStatus("Unsubscribe: "+err.Error(),
5*time.Second)
} else {
app.PushError("Unsubscribe: " + err.Error())
}
return
}
for _, m := range methods {
if m.Scheme == option {
unsubscribe(m)
return
}
}
app.PushError("Unsubscribe: selected method not found")
},
)
app.AddDialog(dialog)
return nil
}
// parseUnsubscribeMethods reads the list-unsubscribe header and parses it as a
// list of angle-bracket <> deliminated URLs. See RFC 2369.
func parseUnsubscribeMethods(header string) (methods []*url.URL) {
r := bufio.NewReader(strings.NewReader(header))
for {
// discard until <
_, err := r.ReadSlice('<')
if err != nil {
return
}
// read until <
m, err := r.ReadSlice('>')
if err != nil {
return
}
m = m[:len(m)-1]
if u, err := url.Parse(string(m)); err == nil {
methods = append(methods, u)
}
}
}
func unsubscribeMailto(u *url.URL, editHeaders, skipEditor bool) error {
widget := app.SelectedTabContent().(app.ProvidesMessage)
acct := widget.SelectedAccount()
if acct == nil {
return errors.New("No account selected")
}
h := &mail.Header{}
h.SetSubject(u.Query().Get("subject"))
if to, err := mail.ParseAddressList(u.Opaque); err == nil {
h.SetAddressList("to", to)
}
composer, err := app.NewComposer(
acct,
acct.AccountConfig(),
acct.Worker(),
editHeaders,
"",
h,
nil,
strings.NewReader(u.Query().Get("body")),
)
if err != nil {
return err
}
composer.Tab = app.NewTab(composer, "unsubscribe")
if skipEditor {
composer.Terminal().Close()
} else {
composer.FocusTerminal()
}
return nil
}
func unsubscribeHTTP(u *url.URL) error {
confirm := app.NewSelectorDialog(
"Do you want to open this link?",
u.String(),
[]string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(),
func(option string, _ error) {
app.CloseDialog()
switch option {
case "Yes":
go func() {
defer log.PanicHandler()
mime := fmt.Sprintf("x-scheme-handler/%s", u.Scheme)
if err := lib.XDGOpenMime(u.String(), mime, ""); err != nil {
app.PushError("Unsubscribe:" + err.Error())
}
}()
default:
app.PushError("Unsubscribe: link will not be opened")
}
},
)
app.AddDialog(confirm)
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package msg
import (
"testing"
)
func TestParseUnsubscribe(t *testing.T) {
type tc struct {
hdr string
expected []string
}
cases := []*tc{
{"", []string{}},
{"invalid", []string{}},
{"<https://example.com>, <http://example.com>", []string{
"https://example.com", "http://example.com",
}},
{"<https://example.com> is a URL", []string{
"https://example.com",
}},
{
"<mailto:user@host?subject=unsubscribe>, <https://example.com>",
[]string{
"mailto:user@host?subject=unsubscribe", "https://example.com",
},
},
{"<>, <https://example> ", []string{
"", "https://example",
}},
}
for _, c := range cases {
result := parseUnsubscribeMethods(c.hdr)
if len(result) != len(c.expected) {
t.Errorf("expected %d methods but got %d", len(c.expected), len(result))
continue
}
for idx := 0; idx < len(result); idx++ {
if result[idx].String() != c.expected[idx] {
t.Errorf("expected %v but got %v", c.expected[idx], result[idx])
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package msg
import (
"errors"
"time"
"git.sr.ht/~rjarry/aerc/app"
"git.sr.ht/~rjarry/aerc/commands"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/models"
)
type helper struct {
msgProvider app.ProvidesMessages
statusInfo func(string)
}
func newHelper() *helper {
msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages)
if !ok {
msgProvider = app.SelectedAccount()
}
return &helper{
msgProvider: msgProvider,
statusInfo: func(s string) {
app.PushStatus(s, 10*time.Second)
},
}
}
func (h *helper) markedOrSelectedUids() ([]models.UID, error) {
return commands.MarkedOrSelected(h.msgProvider)
}
func (h *helper) store() (*lib.MessageStore, error) {
store := h.msgProvider.Store()
if store == nil {
return nil, errors.New("Cannot perform action. Messages still loading")
}
return store, nil
}
func (h *helper) account() (*app.AccountView, error) {
acct := h.msgProvider.SelectedAccount()
if acct == nil {
return nil, errors.New("No account selected")
}
return acct, nil
}
func (h *helper) messages() ([]*models.MessageInfo, error) {
uid, err := commands.MarkedOrSelected(h.msgProvider)
if err != nil {
return nil, err
}
store, err := h.store()
if err != nil {
return nil, err
}
return commands.MsgInfoFromUids(store, uid, h.statusInfo)
}
func getMessagePart(msg *models.MessageInfo, provider app.ProvidesMessage) []int {
p := provider.SelectedMessagePart()
if p != nil {
return p.Index
}
for _, mime := range config.Viewer.Alternatives {
part := lib.FindMIMEPart(mime, msg.BodyStructure, nil)
if part != nil {
return part
}
}
return nil
}