mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-10-24 08:15:07 +02:00
Go has evolved significantly over the years and has introduced some handy helper functions that make the code easier to read. Use helper functions like slices.Contains, map.Copy, and strings.CutPrefix, when appropriate. Signed-off-by: Moritz Poldrack <git@moritz.sh> Acked-by: Robin Jarry <robin@jarry.cc>
334 lines
8.0 KiB
Go
334 lines
8.0 KiB
Go
package compose
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"slices"
|
|
"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."`
|
|
|
|
RequestDSN bool `opt:"--dsn" desc:"Request full delivery status notification (including success)."`
|
|
}
|
|
|
|
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 {
|
|
if slices.Contains(msg.ARCHIVE_TYPES, 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,
|
|
s.RequestDSN)
|
|
}
|
|
}, 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, s.RequestDSN)
|
|
}
|
|
|
|
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, requestDSN 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, requestDSN,
|
|
)
|
|
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
|
|
}
|