diff options
author | Robin Jarry <robin@jarry.cc> | 2022-12-06 20:08:49 +0100 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2022-12-07 15:01:33 +0100 |
commit | cbcabfafaab20eaffad642f20151e890161efddc (patch) | |
tree | 987a666459ee6d3402f1b50c520b5f97a946a181 | |
parent | c4df1eea3ef4ac89d52a9d80a8151d3999215a6a (diff) | |
download | aerc-cbcabfafaab20eaffad642f20151e890161efddc.tar.gz |
compose: allow writing multipart/alternative messages
Add a new :multipart command that can be executed on the composer review
screen. This command takes a MIME type as argument which needs to match
a setting in the new [multipart-converters] section of aerc.conf. A part
can be removed by using the -d flag.
The [multipart-converters] section has MIME types associated with
commands. These commands are executed with sh -c every time the main
email body is updated to generate each part content. The commands are
expected to output valid UTF-8 text.
If a command fails, an explicit error will be printed next to the part
MIME type to allow users to debug their issue but the email may still be
sent anyway with an empty alternative part.
This is mostly intended for people who *really* need to send html
messages for their boss or for corporate reasons. For now, it is
a manual and explicit action to convert a message in such a way.
Here is an example configuration:
[multipart-converters]
text/html = pandoc -f markdown -t html
And the associated binding to append an HTML alternative to a message:
[compose::review]
H = :multipart text/html<enter>
hh = :multipart -d text/html<enter>
Link: https://lists.sr.ht/~rjarry/aerc-discuss/%3CCO5KH4W57XNB.2PZLR1CNFK22H%40mashenka%3E
Co-authored-by: Eric McConville <emcconville@emcconville.com>
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Bence Ferdinandy <bence@ferdinandy.com>
Acked-by: Moritz Poldrack <moritz@poldrack.dev>
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | commands/compose/multipart.go | 73 | ||||
-rw-r--r-- | config/aerc.conf | 11 | ||||
-rw-r--r-- | config/config.go | 5 | ||||
-rw-r--r-- | config/converters.go | 34 | ||||
-rw-r--r-- | doc/aerc-config.5.scd | 26 | ||||
-rw-r--r-- | doc/aerc.1.scd | 11 | ||||
-rw-r--r-- | widgets/compose.go | 66 |
8 files changed, 227 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 555f8c0f..b4b1362f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Search/filter by absolute and relative date ranges with the `-d` flag. - LIST-STATUS support for imap - built-in `wrap` filter that does not mess up nested quotes and lists. +- Write `multipart/alternative` messages with `:multipart` and commands defined + in the new `[multipart-converters]` section in `aerc.conf`. ### Changed diff --git a/commands/compose/multipart.go b/commands/compose/multipart.go new file mode 100644 index 00000000..5a6dd770 --- /dev/null +++ b/commands/compose/multipart.go @@ -0,0 +1,73 @@ +package compose + +import ( + "bytes" + "fmt" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~sircmpwn/getopt" +) + +type Multipart struct{} + +func init() { + register(Multipart{}) +} + +func (Multipart) Aliases() []string { + return []string{"multipart"} +} + +func (Multipart) Complete(aerc *widgets.Aerc, args []string) []string { + var completions []string + completions = append(completions, "-d") + for mime := range aerc.Config().Converters { + completions = append(completions, mime) + } + return commands.CompletionFromList(aerc, completions, args) +} + +func (a Multipart) Execute(aerc *widgets.Aerc, args []string) error { + composer, ok := aerc.SelectedTabContent().(*widgets.Composer) + if !ok { + return fmt.Errorf(":multipart is only available on the compose::review screen") + } + + opts, optind, err := getopt.Getopts(args, "d") + if err != nil { + return fmt.Errorf("Usage: :multipart [-d] <mime/type>") + } + var remove bool = false + for _, opt := range opts { + if opt.Option == 'd' { + remove = true + } + } + args = args[optind:] + if len(args) != 1 { + return fmt.Errorf("Usage: :multipart [-d] <mime/type>") + } + mime := args[0] + + if remove { + return composer.RemovePart(mime) + } else { + _, found := aerc.Config().Converters[mime] + if !found { + return fmt.Errorf("no command defined for MIME type: %s", mime) + } + err = composer.AppendPart( + mime, + map[string]string{"Charset": "UTF-8"}, + // the actual content of the part will be rendered + // every time the body of the email is updated + bytes.NewReader([]byte{}), + ) + if err != nil { + return err + } + } + + return nil +} diff --git a/config/aerc.conf b/config/aerc.conf index ecae5b90..05ebbf41 100644 --- a/config/aerc.conf +++ b/config/aerc.conf @@ -359,6 +359,17 @@ # #no-attachment-warning= +[multipart-converters] +# +# Converters allow to generate multipart/alternative messages by converting the +# main text/plain part into any other MIME type. Only exact MIME types are +# accepted. The commands are invoked with sh -c and are expected to output +# valid UTF-8 text. +# +# Example (obviously, this requires that you write your main text/plain body +# using the markdown syntax): +#text/html=pandoc -f markdown -t html --standalone + [filters] # # Filters allow you to pipe an email body through a shell command to render diff --git a/config/config.go b/config/config.go index c4794cc1..b5cc0d60 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,7 @@ type AercConfig struct { Bindings BindingConfig ContextualBinds []BindingConfigContext Compose ComposeConfig + Converters map[string]string Accounts []AccountConfig `ini:"-"` Filters []FilterConfig `ini:"-"` Viewer ViewerConfig `ini:"-"` @@ -140,6 +141,7 @@ func LoadConfigFromFile(root *string, accts []string) (*AercConfig, error) { Viewer: defaultViewerConfig(), Statusline: defaultStatuslineConfig(), Compose: defaultComposeConfig(), + Converters: make(map[string]string), Templates: defaultTemplatesConfig(), Openers: make(map[string][]string), } @@ -153,6 +155,9 @@ func LoadConfigFromFile(root *string, accts []string) (*AercConfig, error) { if err := config.parseCompose(file); err != nil { return nil, err } + if err := config.parseConverters(file); err != nil { + return nil, err + } if err := config.parseViewer(file); err != nil { return nil, err } diff --git a/config/converters.go b/config/converters.go new file mode 100644 index 00000000..8c6b88df --- /dev/null +++ b/config/converters.go @@ -0,0 +1,34 @@ +package config + +import ( + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/log" + "github.com/go-ini/ini" +) + +func (config *AercConfig) parseConverters(file *ini.File) error { + converters, err := file.GetSection("multipart-converters") + if err != nil { + goto out + } + + for mimeType, command := range converters.KeysHash() { + mimeType = strings.ToLower(mimeType) + if mimeType == "text/plain" { + return fmt.Errorf( + "multipart-converters: text/plain is reserved") + } + if !strings.HasPrefix(mimeType, "text/") { + return fmt.Errorf( + "multipart-converters: %q: only text/* MIME types are supported", + mimeType) + } + config.Converters[mimeType] = command + } + +out: + log.Debugf("aerc.conf: [multipart-converters] %#v", config.Converters) + return nil +} diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index 868f6564..4fd04bfa 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -569,6 +569,32 @@ These options are configured in the *[compose]* section of _aerc.conf_. Example: *no-attachment-warning* = _^[^>]\*attach(ed|ment)_ +# MULTIPART CONVERTERS + +Converters allow to generate _multipart/alternative_ messages by converting the +main _text/plain_ body into any other text MIME type with the *:multipart* +command. Only exact MIME types are accepted. The commands are invoked with +_sh -c_ and are expected to output valid UTF-8 text. + +Only _text/<subtype>_ MIME parts can be generated. The _text/plain_ MIME type is +reserved and cannot be generated. You still need to write your emails by hand in +your favorite text editor. + +Converters are configured in the *[multipart-converters]* section of +_aerc.conf_. + +Example: + +``` +[multipart-converters] +text/html=pandoc -f markdown -t html --standalone +``` + +Obviously, this requires that you write your main _text/plain_ body using the +markdown syntax. Also, mind that some mailing lists reject emails that contain +_text/html_ alternative parts. Use this feature carefully and when possible, +avoid using it at all. + # FILTERS Filters are a flexible and powerful way of handling viewing parts of an opened diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd index 5b5add89..b477fbaf 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -524,6 +524,17 @@ message list, the message in the message viewer, etc). *:edit* (Re-)opens your text editor to edit the message in progress. +*:multipart* [*-d*] _<mime/type>_ + Makes the message to multipart/alternative and add the specified + _<mime/type>_ part. Only the MIME types that are configured in the + *[multipart-converters]* section of _aerc.conf_ are supported and their + related commands will be used to generate the alternate part. + + *-d*: + Remove the specified alternative _<mime/type>_ instead of + adding it. If no alternative parts are left, make the message + text/plain (i.e. not multipart/alternative). + *:next-field*++ *:prev-field* Cycles between input fields in the compose window. diff --git a/widgets/compose.go b/widgets/compose.go index 30c5268d..37e09ccb 100644 --- a/widgets/compose.go +++ b/widgets/compose.go @@ -464,6 +464,11 @@ func (c *Composer) AppendPart(mimetype string, params map[string]string, body io if !strings.HasPrefix(mimetype, "text") { return fmt.Errorf("can only append text mimetypes") } + for _, part := range c.textParts { + if part.MimeType == mimetype { + return fmt.Errorf("%s part already exists", mimetype) + } + } newPart, err := lib.NewPart(mimetype, params, body) if err != nil { return err @@ -473,6 +478,21 @@ func (c *Composer) AppendPart(mimetype string, params map[string]string, body io return nil } +func (c *Composer) RemovePart(mimetype string) error { + if mimetype == "text/plain" { + return fmt.Errorf("cannot remove text/plain parts") + } + for i, part := range c.textParts { + if part.MimeType != mimetype { + continue + } + c.textParts = append(c.textParts[:i], c.textParts[i+1:]...) + c.resetReview() + return nil + } + return fmt.Errorf("%s part not found", mimetype) +} + func (c *Composer) AddTemplate(template string, data interface{}) error { if template == "" { return nil @@ -1361,7 +1381,15 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage { grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) i += 1 for _, p := range composer.textParts { - grid.AddChild(ui.NewText(p.MimeType, uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) + err := composer.updateMultipart(p) + if err != nil { + msg := fmt.Sprintf("%s error: %s", p.MimeType, err) + grid.AddChild(ui.NewText(msg, + uiConfig.GetStyle(config.STYLE_ERROR))).At(i, 0) + } else { + grid.AddChild(ui.NewText(p.MimeType, + uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) + } i += 1 } @@ -1374,6 +1402,42 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage { } } +func (c *Composer) updateMultipart(p *lib.Part) error { + command, found := c.aerc.Config().Converters[p.MimeType] + if !found { + // unreachable + return fmt.Errorf("no command defined for mime/type") + } + // reset part body to avoid it leaving outdated if the command fails + p.Data = nil + err := c.reloadEmail() + if err != nil { + return errors.Wrap(err, "reloadEmail") + } + body, err := io.ReadAll(c.email) + if err != nil { + return errors.Wrap(err, "io.ReadAll") + } + cmd := exec.Command("sh", "-c", command) + cmd.Stdin = bytes.NewReader(body) + out, err := cmd.Output() + if err != nil { + var stderr string + var ee *exec.ExitError + if errors.As(err, &ee) { + // append the first 30 chars of stderr if any + stderr = strings.Trim(string(ee.Stderr), " \t\n\r") + stderr = strings.ReplaceAll(stderr, "\n", "; ") + if stderr != "" { + stderr = fmt.Sprintf(": %.30s", stderr) + } + } + return fmt.Errorf("%s: %w%s", command, err, stderr) + } + p.Data = out + return nil +} + func (rm *reviewMessage) Invalidate() { ui.Invalidate() } |