diff options
-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() } |