aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md2
-rw-r--r--commands/compose/multipart.go73
-rw-r--r--config/aerc.conf11
-rw-r--r--config/config.go5
-rw-r--r--config/converters.go34
-rw-r--r--doc/aerc-config.5.scd26
-rw-r--r--doc/aerc.1.scd11
-rw-r--r--widgets/compose.go66
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()
}