aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKarel Balej <balejk@matfyz.cz>2024-01-30 20:11:29 +0100
committerRobin Jarry <robin@jarry.cc>2024-02-12 23:05:53 +0100
commit3aa8b6308482d2e3feea0fecb065190cf05c2468 (patch)
tree99e9d3c9f9b6a65beb7c3a8c981e2dc795d8b845
parente8a6e8316a4b6e923f75b1e9a2d06089033e480b (diff)
downloadaerc-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.go210
-rw-r--r--config/binds.conf2
-rw-r--r--doc/aerc.1.scd16
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