Files
aerc-fork-mirror/commands/msg/reply.go
Robin Jarry 0211c9bf23 reload: fix crash when reloading via IPC
When reloading the configuration with :reload, global variables in the
config package are reset to their startup values and then, the config is
parsed from disk. While the parsing is done, these variables are
temporarily in an inconsistent and possibly invalid state.

When commands are executed interactively from aerc, they are handled by
the main goroutine which also deals with UI rendering. No UI render will
be done while :reload is in progress.

However, the IPC socket handler runs in an independent goroutine. This
has the unfortunate side effect to let the UI goroutine to run while
config parsing is in progress and causes crashes:

[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6bb142]

goroutine 1 [running]:
git.sr.ht/~rjarry/aerc/lib/log.PanicHandler()
	lib/log/panic-logger.go:51 +0x6cf
panic({0xc1d960?, 0x134a6e0?})
	/usr/lib/go/src/runtime/panic.go:783 +0x132
git.sr.ht/~rjarry/aerc/config.(*StyleConf).getStyle(0xc00038b908?, 0x4206b7?)
	config/style.go:386 +0x42
git.sr.ht/~rjarry/aerc/config.StyleSet.Get({0x0, 0x0, 0x0, {0x0, 0x0, 0x0}}, 0x421a65?, 0x0)
	config/style.go:408 +0x8b
git.sr.ht/~rjarry/aerc/config.(*UIConfig).GetStyle(...)
	config/ui.go:379
git.sr.ht/~rjarry/aerc/lib/ui.(*TabStrip).Draw(0xc000314700, 0xc000192230)
	lib/ui/tab.go:378 +0x15b
git.sr.ht/~rjarry/aerc/lib/ui.(*Grid).Draw(0xc000186fc0, 0xc0002c25f0)
	lib/ui/grid.go:126 +0x28e
git.sr.ht/~rjarry/aerc/app.(*Aerc).Draw(0x14b9f00, 0xc0002c25f0)
	app/aerc.go:192 +0x1fe
git.sr.ht/~rjarry/aerc/lib/ui.Render()
	lib/ui/ui.go:155 +0x16b
main.main()
	main.go:310 +0x997

Make the reload operation safe by changing how config objects are
exposed and updated. Change all objects to be atomic pointers. Expose
public functions to access their value atomically. Only update their
value after a complete and successful config parse. This way the UI
thread will always have access to a valid configuration.

NB: The account configuration is not included in this change since it
cannot be reloaded.

Fixes: https://todo.sr.ht/~rjarry/aerc/319
Reported-by: Anachron <gith@cron.world>
Signed-off-by: Robin Jarry <robin@jarry.cc>
2025-09-08 12:19:51 +02:00

370 lines
9.1 KiB
Go

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, err := chooseFromAddr(conf, msg)
if err != nil {
return err
}
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 {
// We accept to reply to ourselves, so don't exclude our own address
// from the reply's recipients.
} else {
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
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, error) {
if len(conf.Aliases) == 0 {
return conf.From, nil
}
rec := newAddrSet()
rec.AddList(msg.Envelope.From)
rec.AddList(msg.Envelope.To)
rec.AddList(msg.Envelope.Cc)
if conf.OriginalToHeader != "" && msg.RFC822Headers.Has(conf.OriginalToHeader) {
origTo, err := msg.RFC822Headers.Text(conf.OriginalToHeader)
if err != nil {
return nil, err
}
origToAddress, err := mail.ParseAddressList(origTo)
if err != nil {
return nil, err
}
rec.AddList(origToAddress)
}
// 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}, nil
}
}
}
return conf.From, nil
}
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))
}