compose: warn before sending without attachment

Prevent the embarrassing forgotten attachment scenario by warning the
user before sending a message that may need an attachment but does not
have one. Whether a message needs an attachment is determined by testing
a configurable regex against the message body.

Signed-off-by: Jason Cox <dev@jasoncarloscox.com>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Jason Cox 2022-10-17 13:19:33 -04:00 committed by Robin Jarry
parent 8ffcd3e5ad
commit 7647dfb8b4
7 changed files with 106 additions and 6 deletions

View file

@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Support for attaching files with `mailto:`-links - Support for attaching files with `mailto:`-links
- Filter commands now have the `AERC_MIME_TYPE` and `AERC_FILENAME` variables - Filter commands now have the `AERC_MIME_TYPE` and `AERC_FILENAME` variables
defined in their environment. defined in their environment.
- Warn before sending emails that may need an attachment with
`no-attachment-warning` in `aerc.conf`.
### Changed ### Changed

View file

@ -100,6 +100,40 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
rcpts: rcpts, rcpts: rcpts,
} }
warn, err := composer.ShouldWarnAttachment()
if err != nil || warn {
msg := "You may have forgotten an attachment."
if err != nil {
logging.Warnf("failed to check for a forgotten attachment: %v", err)
msg = "Failed to check for a forgotten attachment."
}
prompt := widgets.NewPrompt(aerc.Config(),
msg+" Abort send? [Y/n] ",
func(text string) {
if text == "n" || text == "N" {
send(aerc, composer, ctx, header, tabName)
}
}, func(cmd string) ([]string, string) {
if cmd == "" {
return []string{"y", "n"}, ""
}
return nil, ""
},
)
aerc.PushPrompt(prompt)
} else {
send(aerc, composer, ctx, header, tabName)
}
return nil
}
func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx,
header *mail.Header, tabName string,
) {
// we don't want to block the UI thread while we are sending // 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 // so we do everything in a goroutine and hide the composer from the user
aerc.RemoveTab(composer) aerc.RemoveTab(composer)
@ -109,6 +143,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
mode.NoQuit() mode.NoQuit()
var copyBuf bytes.Buffer // for the Sent folder content if CopyTo is set var copyBuf bytes.Buffer // for the Sent folder content if CopyTo is set
config := composer.Config()
failCh := make(chan error) failCh := make(chan error)
// writer // writer
@ -116,6 +151,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
defer logging.PanicHandler() defer logging.PanicHandler()
var sender io.WriteCloser var sender io.WriteCloser
var err error
switch ctx.scheme { switch ctx.scheme {
case "smtp": case "smtp":
fallthrough fallthrough
@ -151,7 +187,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
// leave no-quit mode // leave no-quit mode
defer mode.NoQuitDone() defer mode.NoQuitDone()
err = <-failCh err := <-failCh
if err != nil { if err != nil {
aerc.PushError(strings.ReplaceAll(err.Error(), "\n", " ")) aerc.PushError(strings.ReplaceAll(err.Error(), "\n", " "))
aerc.NewTab(composer, tabName) aerc.NewTab(composer, tabName)
@ -176,7 +212,6 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error {
composer.SetSent() composer.SetSent()
composer.Close() composer.Close()
}() }()
return nil
} }
func listRecipients(h *mail.Header) ([]*mail.Address, error) { func listRecipients(h *mail.Header) ([]*mail.Address, error) {

View file

@ -297,6 +297,18 @@ address-book-cmd=
# Default: true # Default: true
reply-to-self=true reply-to-self=true
#
# Warn before sending an email that matches the specified regexp but does not
# have any attachments. Leave empty to disable this feature.
#
# Uses Go's regexp syntax, documented at https://golang.org/s/re2syntax. The
# "(?im)" flags are set by default (case-insensitive and multi-line).
#
# Example:
# no-attachment-warning=^[^>]*attach(ed|ment)
#
no-attachment-warning=
[filters] [filters]
# #
# Filters allow you to pipe an email body through a shell command to render # Filters allow you to pipe an email body through a shell command to render

View file

@ -207,6 +207,7 @@ type ComposeConfig struct {
HeaderLayout [][]string `ini:"-"` HeaderLayout [][]string `ini:"-"`
AddressBookCmd string `ini:"address-book-cmd"` AddressBookCmd string `ini:"address-book-cmd"`
ReplyToSelf bool `ini:"reply-to-self"` ReplyToSelf bool `ini:"reply-to-self"`
NoAttachmentWarning *regexp.Regexp `ini:"-"`
} }
type FilterConfig struct { type FilterConfig struct {
@ -523,6 +524,18 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
if key == "header-layout" { if key == "header-layout" {
config.Compose.HeaderLayout = parseLayout(val) config.Compose.HeaderLayout = parseLayout(val)
} }
if key == "no-attachment-warning" && len(val) > 0 {
re, err := regexp.Compile("(?im)" + val)
if err != nil {
return fmt.Errorf(
"Invalid no-attachment-warning '%s': %w",
val, err,
)
}
config.Compose.NoAttachmentWarning = re
}
} }
} }

View file

@ -493,6 +493,20 @@ These options are configured in the *[compose]* section of aerc.conf.
Default: true Default: true
*no-attachment-warning*
Specifies a regular expression against which an email's body should be
tested before sending an email with no attachment. If the regexp
matches, aerc will warn you before sending the message. Leave empty to
disable this feature.
Uses Go's regexp syntax, documented at https://golang.org/s/re2syntax.
The "(?im)" flags are set by default (case-insensitive and multi-line).
Example:
no-attachment-warning=^[^>]\*attach(ed|ment)
Default: none
## FILTERS ## FILTERS
Filters allow you to pipe an email body through a shell command to render Filters allow you to pipe an email body through a shell command to render

View file

@ -598,6 +598,10 @@ func (aerc *Aerc) BeginExCommand(cmd string) {
aerc.focus(exline) aerc.focus(exline)
} }
func (aerc *Aerc) PushPrompt(prompt *ExLine) {
aerc.prompts.Push(prompt)
}
func (aerc *Aerc) RegisterPrompt(prompt string, cmd []string) { func (aerc *Aerc) RegisterPrompt(prompt string, cmd []string) {
p := NewPrompt(aerc.conf, prompt, func(text string) { p := NewPrompt(aerc.conf, prompt, func(text string) {
if text != "" { if text != "" {

View file

@ -776,6 +776,26 @@ func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
} }
} }
func (c *Composer) ShouldWarnAttachment() (bool, error) {
regex := c.config.Compose.NoAttachmentWarning
if regex == nil || len(c.attachments) > 0 {
return false, nil
}
err := c.reloadEmail()
if err != nil {
return false, errors.Wrap(err, "reloadEmail")
}
body, err := io.ReadAll(c.email)
if err != nil {
return false, errors.Wrap(err, "io.ReadAll")
}
return regex.Match(body), nil
}
func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error { func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error {
if len(c.attachments) == 0 && len(c.textParts) == 0 { if len(c.attachments) == 0 && len(c.textParts) == 0 {
// no attachments // no attachments