mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-02-22 23:23:57 +01:00

In some setups it is useful to copy an email to multiple destinations, e.g. to automatically put sent emails in a folder with "interesting" emails. This allows doing so. Changelog-added: `copy-to` now supports multiple destination folders. Signed-off-by: Jelte Fennema-Nio <aerc@jeltef.nl> Acked-by: Robin Jarry <robin@jarry.cc>
328 lines
7.8 KiB
Go
328 lines
7.8 KiB
Go
package compose
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/commands/msg"
|
|
"git.sr.ht/~rjarry/aerc/lib/hooks"
|
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
|
"git.sr.ht/~rjarry/aerc/lib/send"
|
|
"git.sr.ht/~rjarry/aerc/models"
|
|
"git.sr.ht/~rjarry/aerc/worker/types"
|
|
"git.sr.ht/~rjarry/go-opt/v2"
|
|
"github.com/emersion/go-message/mail"
|
|
)
|
|
|
|
type Send struct {
|
|
Archive string `opt:"-a" action:"ParseArchive" metavar:"flat|year|month" complete:"CompleteArchive" desc:"Archive the message being replied to."`
|
|
CopyTo []string `opt:"-t" complete:"CompleteFolders" action:"ParseCopyTo" desc:"Override the Copy-To folders."`
|
|
|
|
CopyToReplied bool `opt:"-r" desc:"Save sent message to current folder."`
|
|
NoCopyToReplied bool `opt:"-R" desc:"Do not save sent message to current folder."`
|
|
}
|
|
|
|
func init() {
|
|
commands.Register(Send{})
|
|
}
|
|
|
|
func (Send) Description() string {
|
|
return "Send the message using the configured outgoing transport."
|
|
}
|
|
|
|
func (Send) Context() commands.CommandContext {
|
|
return commands.COMPOSE_REVIEW
|
|
}
|
|
|
|
func (Send) Aliases() []string {
|
|
return []string{"send"}
|
|
}
|
|
|
|
func (*Send) CompleteArchive(arg string) []string {
|
|
return commands.FilterList(msg.ARCHIVE_TYPES, arg, nil)
|
|
}
|
|
|
|
func (*Send) CompleteFolders(arg string) []string {
|
|
return commands.GetFolders(arg)
|
|
}
|
|
|
|
func (s *Send) ParseArchive(arg string) error {
|
|
for _, a := range msg.ARCHIVE_TYPES {
|
|
if a == arg {
|
|
s.Archive = arg
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("unsupported archive type")
|
|
}
|
|
|
|
func (o *Send) ParseCopyTo(arg string) error {
|
|
o.CopyTo = append(o.CopyTo, strings.Split(arg, ",")...)
|
|
return nil
|
|
}
|
|
|
|
func (s Send) Execute(args []string) error {
|
|
tab := app.SelectedTab()
|
|
if tab == nil {
|
|
return errors.New("No selected tab")
|
|
}
|
|
composer, _ := tab.Content.(*app.Composer)
|
|
|
|
err := composer.CheckForMultipartErrors()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
config := composer.Config()
|
|
|
|
if len(s.CopyTo) == 0 {
|
|
s.CopyTo = config.CopyTo
|
|
}
|
|
copyToReplied := config.CopyToReplied || (s.CopyToReplied && !s.NoCopyToReplied)
|
|
|
|
outgoing, err := config.Outgoing.ConnectionString()
|
|
if err != nil {
|
|
return errors.Wrap(err, "ReadCredentials(outgoing)")
|
|
}
|
|
if outgoing == "" {
|
|
return errors.New(
|
|
"No outgoing mail transport configured for this account")
|
|
}
|
|
|
|
header, err := composer.PrepareHeader()
|
|
if err != nil {
|
|
return errors.Wrap(err, "PrepareHeader")
|
|
}
|
|
rcpts, err := listRecipients(header)
|
|
if err != nil {
|
|
return errors.Wrap(err, "listRecipients")
|
|
}
|
|
if len(rcpts) == 0 {
|
|
return errors.New("Cannot send message with no recipients")
|
|
}
|
|
|
|
if config.StripBcc {
|
|
// Do NOT leak Bcc addresses to all recipients.
|
|
header.Del("Bcc")
|
|
}
|
|
|
|
uri, err := url.Parse(outgoing)
|
|
if err != nil {
|
|
return errors.Wrap(err, "url.Parse(outgoing)")
|
|
}
|
|
|
|
var domain string
|
|
if domain_, ok := config.Params["smtp-domain"]; ok {
|
|
domain = domain_
|
|
}
|
|
from := config.From
|
|
if config.UseEnvelopeFrom {
|
|
if fl, _ := header.AddressList("from"); len(fl) != 0 {
|
|
from = fl[0]
|
|
}
|
|
}
|
|
|
|
log.Debugf("send config uri: %s", uri.Redacted())
|
|
log.Debugf("send config from: %s", from)
|
|
log.Debugf("send config rcpts: %s", rcpts)
|
|
log.Debugf("send config domain: %s", domain)
|
|
|
|
warnSubject := composer.ShouldWarnSubject()
|
|
warnAttachment := composer.ShouldWarnAttachment()
|
|
if warnSubject || warnAttachment {
|
|
var msg string
|
|
switch {
|
|
case warnSubject && warnAttachment:
|
|
msg = "The subject is empty, and you may have forgotten an attachment."
|
|
case warnSubject:
|
|
msg = "The subject is empty."
|
|
default:
|
|
msg = "You may have forgotten an attachment."
|
|
}
|
|
|
|
prompt := app.NewPrompt(
|
|
msg+" Abort send? [Y/n] ",
|
|
func(text string) {
|
|
if text == "n" || text == "N" {
|
|
sendHelper(composer, header, uri, domain,
|
|
from, rcpts, tab.Name, s.CopyTo,
|
|
s.Archive, copyToReplied)
|
|
}
|
|
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
|
var comps []opt.Completion
|
|
if cmd == "" {
|
|
comps = append(comps, opt.Completion{Value: "y"})
|
|
comps = append(comps, opt.Completion{Value: "n"})
|
|
}
|
|
return comps, ""
|
|
},
|
|
)
|
|
|
|
app.PushPrompt(prompt)
|
|
} else {
|
|
sendHelper(composer, header, uri, domain, from, rcpts, tab.Name,
|
|
s.CopyTo, s.Archive, copyToReplied)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domain string,
|
|
from *mail.Address, rcpts []*mail.Address, tabName string, copyTo []string,
|
|
archive string, copyToReplied bool,
|
|
) {
|
|
// we don't want to block the UI thread while we are sending
|
|
// so we do everything in a goroutine and hide the composer from the user
|
|
app.RemoveTab(composer, false)
|
|
app.PushStatus("Sending...", 10*time.Second)
|
|
|
|
// enter no-quit mode
|
|
mode.NoQuit()
|
|
|
|
var shouldCopy bool = (len(copyTo) > 0 || copyToReplied) && !strings.HasPrefix(uri.Scheme, "jmap")
|
|
var copyBuf bytes.Buffer
|
|
|
|
failCh := make(chan error)
|
|
// writer
|
|
go func() {
|
|
defer log.PanicHandler()
|
|
|
|
var folders []string
|
|
folders = append(folders, copyTo...)
|
|
if copyToReplied && composer.Parent() != nil {
|
|
folders = append(folders, composer.Parent().Folder)
|
|
}
|
|
sender, err := send.NewSender(
|
|
composer.Worker(), uri, domain, from, rcpts, folders)
|
|
if err != nil {
|
|
failCh <- errors.Wrap(err, "send:")
|
|
return
|
|
}
|
|
|
|
var writer io.Writer = sender
|
|
|
|
if shouldCopy {
|
|
writer = io.MultiWriter(writer, ©Buf)
|
|
}
|
|
|
|
err = composer.WriteMessage(header, writer)
|
|
if err != nil {
|
|
failCh <- err
|
|
return
|
|
}
|
|
failCh <- sender.Close()
|
|
}()
|
|
|
|
// cleanup + copy to sent
|
|
go func() {
|
|
defer log.PanicHandler()
|
|
|
|
// leave no-quit mode
|
|
defer mode.NoQuitDone()
|
|
|
|
err := <-failCh
|
|
if err != nil {
|
|
app.PushError(strings.ReplaceAll(err.Error(), "\n", " "))
|
|
app.NewTab(composer, tabName)
|
|
return
|
|
}
|
|
if shouldCopy {
|
|
app.PushStatus("Copying to copy-to folders", 10*time.Second)
|
|
errch := copyToSent(copyTo, copyToReplied, copyBuf.Len(),
|
|
©Buf, composer)
|
|
err = <-errch
|
|
if err != nil {
|
|
errmsg := fmt.Sprintf(
|
|
"message sent, but copying to %v failed: %v",
|
|
copyTo, err.Error())
|
|
app.PushError(errmsg)
|
|
composer.SetSent(archive)
|
|
composer.Close()
|
|
return
|
|
}
|
|
}
|
|
app.PushStatus("Message sent.", 10*time.Second)
|
|
composer.SetSent(archive)
|
|
err = hooks.RunHook(&hooks.MailSent{
|
|
Account: composer.Account().Name(),
|
|
Backend: composer.Account().AccountConfig().Backend,
|
|
Header: header,
|
|
})
|
|
if err != nil {
|
|
log.Errorf("failed to trigger mail-sent hook: %v", err)
|
|
composer.Account().PushError(fmt.Errorf("[hook.mail-sent] failed: %w", err))
|
|
}
|
|
composer.Close()
|
|
}()
|
|
}
|
|
|
|
func listRecipients(h *mail.Header) ([]*mail.Address, error) {
|
|
var rcpts []*mail.Address
|
|
for _, key := range []string{"to", "cc", "bcc"} {
|
|
list, err := h.AddressList(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rcpts = append(rcpts, list...)
|
|
}
|
|
return rcpts, nil
|
|
}
|
|
|
|
func copyToSent(dests []string, copyToReplied bool, n int, msg *bytes.Buffer, composer *app.Composer) <-chan error {
|
|
errCh := make(chan error, 1)
|
|
acct := composer.Account()
|
|
if acct == nil {
|
|
errCh <- errors.New("No account selected")
|
|
return errCh
|
|
}
|
|
store := acct.Store()
|
|
if store == nil {
|
|
errCh <- errors.New("No message store selected")
|
|
return errCh
|
|
}
|
|
for _, dest := range dests {
|
|
store.Append(
|
|
dest,
|
|
models.SeenFlag,
|
|
time.Now(),
|
|
bytes.NewReader(msg.Bytes()),
|
|
n,
|
|
func(msg types.WorkerMessage) {
|
|
switch msg := msg.(type) {
|
|
case *types.Done:
|
|
errCh <- nil
|
|
case *types.Error:
|
|
errCh <- msg.Error
|
|
}
|
|
},
|
|
)
|
|
}
|
|
if copyToReplied && composer.Parent() != nil {
|
|
store.Append(
|
|
composer.Parent().Folder,
|
|
models.SeenFlag,
|
|
time.Now(),
|
|
bytes.NewReader(msg.Bytes()),
|
|
n,
|
|
func(msg types.WorkerMessage) {
|
|
switch msg := msg.(type) {
|
|
case *types.Done:
|
|
errCh <- nil
|
|
case *types.Error:
|
|
errCh <- msg.Error
|
|
}
|
|
},
|
|
)
|
|
}
|
|
return errCh
|
|
}
|