diff options
author | Karel Balej <balejk@matfyz.cz> | 2024-01-30 20:11:29 +0100 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2024-02-12 23:05:53 +0100 |
commit | 3aa8b6308482d2e3feea0fecb065190cf05c2468 (patch) | |
tree | 99e9d3c9f9b6a65beb7c3a8c981e2dc795d8b845 | |
parent | e8a6e8316a4b6e923f75b1e9a2d06089033e480b (diff) | |
download | aerc-3aa8b6308482d2e3feea0fecb065190cf05c2468.tar.gz |
commands: add bounce
Add a command to allow for reintroduction of messages into the transport
system. This means taking a message and forwarding it to new recipients
as is including original headers. The fact that the message has been
bounced is indicated by the prepend of *Resent-* headers in accordance
with RFC 2822. The bounced message is not stored in the sent mailbox.
Also add an `-A` switch to allow for bouncing using different account
than the one currently selected.
Also add default keybind and documentation entry for this command.
The mentioned RFC also recognizes *Resent-Cc* and *Resent-Bcc* headers
which might be an interesting continuation of this -- currently all
recipients are specified in *Resent-To*. Also more control over the
*Resent-From* header value could be implemented.
This command is strongly inspired by (neo)mutt's `bounce`.
Implements: https://todo.sr.ht/~rjarry/aerc/115
Changelog-added: `:bounce` command to reintroduce messages into the
transport system.
Signed-off-by: Karel Balej <balejk@matfyz.cz>
Acked-by: Robin Jarry <robin@jarry.cc>
-rw-r--r-- | commands/msg/bounce.go | 210 | ||||
-rw-r--r-- | config/binds.conf | 2 | ||||
-rw-r--r-- | doc/aerc.1.scd | 16 |
3 files changed, 228 insertions, 0 deletions
diff --git a/commands/msg/bounce.go b/commands/msg/bounce.go new file mode 100644 index 00000000..46445bdd --- /dev/null +++ b/commands/msg/bounce.go @@ -0,0 +1,210 @@ +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/send" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Bounce struct { + Account string `opt:"-A" complete:"CompleteAccount"` + To []string `opt:"..." required:"true" complete:"CompleteTo"` +} + +func init() { + commands.Register(Bounce{}) +} + +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 +} + +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); 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 +} diff --git a/config/binds.conf b/config/binds.conf index 125a906b..33fc8454 100644 --- a/config/binds.conf +++ b/config/binds.conf @@ -64,6 +64,8 @@ A = :unmark -a<Enter>:mark -T<Enter>:archive flat<Enter> C = :compose<Enter> m = :compose<Enter> +b = :bounce<space> + rr = :reply -a<Enter> rq = :reply -aq<Enter> Rr = :reply<Enter> diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd index e3079090..e4a3305f 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -504,6 +504,22 @@ message list, the message in the message viewer, etc). _<body>_: The initial message body. +*:bounce* [*-A* _<account>_] _<address>_ [_<address>_...]++ +*:resend* [*-A* _<account>_] _<address>_ [_<address>_...] + Bounce the selected message or all marked messages to the specified addresses, + optionally using the specified account. This forwards the message while + preserving all the existing headers. The new sender (*From*), date (*Date*), + *Message-ID* and recipients (*To*) are prepended to the headers with the *Resent-* + prefix. For more information please refer to section 3.6.6 of RFC 2822. Note + that the bounced message is not copied over to the *sent* folder. + + Beware that at least the _msmtp_ sendmail implementation does not consider + the *Resent-From* header when invoked with _--read-envelope-from_. + + Also please note that some providers (notably for instance Microsoft's + O365) do not allow sending messages with the *From* header not matching + any of the account's identities (even if *Resent-From* matches some). + *:recover* [*-f*] [*-e*|*-E*] _<file>_ Resume composing a message that was not sent nor postponed. The file may not contain header data unless *[compose].edit-headers* was enabled when |