--- /dev/null
+#!/usr/bin/python3
+#
+# NeoMutt helper script to create multipart/* emails with Markdown → HTML
+# alternative conversion, and handling of inline images, using NeoMutt's
+# ability to manually craft MIME trees, but automating this process.
+#
+# Configuration:
+# neomuttrc (needs to be a single line):
+# macro compose B "\
+# <enter-command> source '$my_confdir/buildmimetree.py setup|'<enter>\
+# <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
+# " "Convert message into a modern MIME tree with inline images"
+#
+# (Yes, we need to call source twice, as mutt only starts to process output
+# from a source command when the command exits, and since we need to react
+# to the output, we need to be invoked again, using a $my_ variable to pass
+# information)
+#
+# Requirements:
+# - python3
+# - python3-markdown
+# Optional:
+# - pytest
+# - Pynliner
+# - Pygments, if installed, then syntax highlighting is enabled
+#
+# Latest version:
+# https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
+#
+# Copyright © 2023 martin f. krafft <madduck@madduck.net>
+# Released under the GPL-2+ licence, just like Mutt itself.
+#
+
+import sys
+import pathlib
+import markdown
+import tempfile
+import argparse
+from collections import namedtuple
+
+
+def parse_cli_args(*args, **kwargs):
+ parser = argparse.ArgumentParser(
+ description=(
+ "NeoMutt helper to turn text/markdown email parts "
+ "into full-fledged MIME trees"
+ )
+ )
+ parser.epilog = (
+ "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
+ "Released under the MIT licence"
+ )
+
+ subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
+ parser_setup = subp.add_parser("setup", help="Setup phase")
+ parser_massage = subp.add_parser("massage", help="Massaging phase")
+
+ parser_setup.add_argument(
+ "--debug-commands",
+ action="store_true",
+ help="Turn on debug logging of commands generated to stderr",
+ )
+
+ parser_setup.add_argument(
+ "--extension",
+ "-x",
+ metavar="EXTENSION",
+ dest="extensions",
+ nargs="?",
+ default=[],
+ action="append",
+ help="Markdown extension to add to the list of extensions use",
+ )
+
+ parser_massage.add_argument(
+ "--debug-commands",
+ action="store_true",
+ help="Turn on debug logging of commands generated to stderr",
+ )
+
+ parser_massage.add_argument(
+ "--debug-walk",
+ action="store_true",
+ help="Turn on debugging to stderr of the MIME tree walk",
+ )
+
+ parser_massage.add_argument(
+ "--extensions",
+ metavar="EXTENSIONS",
+ type=str,
+ default="",
+ help="Markdown extension to use (comma-separated list)",
+ )
+
+ parser_massage.add_argument(
+ "--write-commands-to",
+ metavar="PATH",
+ dest="cmdpath",
+ help="Temporary file path to write commands to",
+ )
+
+ parser_massage.add_argument(
+ "MAILDRAFT",
+ nargs="?",
+ help="If provided, the script is invoked as editor on the mail draft",
+ )
+
+ return parser.parse_args(*args, **kwargs)
+
+
+# [ PARTS GENERATION ] ########################################################
+
+
+class Part(
+ namedtuple(
+ "Part",
+ ["type", "subtype", "path", "desc", "cid", "orig"],
+ defaults=[None, None, False],
+ )
+):
+ def __str__(self):
+ ret = f"<{self.type}/{self.subtype}>"
+ if self.cid:
+ ret = f"{ret} cid:{self.cid}"
+ if self.orig:
+ ret = f"{ret} ORIGINAL"
+ return ret
+
+
+class Multipart(
+ namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
+):
+ def __str__(self):
+ return f"<multipart/{self.subtype}> children={len(self.children)}"
+
+
+def convert_markdown_to_html(maildraft, *, extensions=None):
+ draftpath = pathlib.Path(maildraft)
+ textpart = Part(
+ "text", "plain", draftpath, "Plain-text version", orig=True
+ )
+
+ with open(draftpath, "r", encoding="utf-8") as textmarkdown:
+ text = textmarkdown.read()
+
+ mdwn = markdown.Markdown(extensions=extensions)
+ html = mdwn.convert(text)
+
+ htmlpath = draftpath.with_suffix(".html")
+ htmlpart = Part("text", "html", htmlpath, "HTML version")
+
+ with open(
+ htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
+ ) as texthtml:
+ texthtml.write(html)
+
+ logopart = Part(
+ "image",
+ "png",
+ "/usr/share/doc/neomutt/logo/neomutt-256.png",
+ "Logo",
+ "neomutt-256.png",
+ )
+
+ return Multipart(
+ "relative",
+ [
+ Multipart(
+ "alternative",
+ [textpart, htmlpart],
+ "Group of alternative content",
+ ),
+ logopart,
+ ],
+ "Group of related content",
+ )
+
+
+class MIMETreeDFWalker:
+ def __init__(self, *, visitor_fn=None, debug=False):
+ self._visitor_fn = visitor_fn
+ self._debug = debug
+
+ def walk(self, root, *, visitor_fn=None):
+ """
+ Recursive function to implement a depth-dirst walk of the MIME-tree
+ rooted at `root`.
+ """
+
+ if isinstance(root, list):
+ root = Multipart("mixed", children=root)
+
+ self._walk(
+ root,
+ stack=[],
+ visitor_fn=visitor_fn or self._visitor_fn,
+ )
+
+ def _walk(self, node, *, stack, visitor_fn):
+ # Let's start by enumerating the parts at the current level. At the
+ # root level, stack will be the empty list, and we expect a multipart/*
+ # container at this level. Later, e.g. within a mutlipart/alternative
+ # container, the subtree will just be the alternative parts, while the
+ # top of the stack will be the multipart/alternative container, which
+ # we will process after the following loop.
+
+ lead = f"{'| '*len(stack)}|-"
+ if isinstance(node, Multipart):
+ self.debugprint(
+ f"{lead}{node} parents={[s.subtype for s in stack]}"
+ )
+
+ # Depth-first, so push the current container onto the stack,
+ # then descend …
+ stack.append(node)
+ self.debugprint("| " * (len(stack) + 1))
+ for child in node.children:
+ self._walk(
+ child,
+ stack=stack,
+ visitor_fn=visitor_fn,
+ )
+ self.debugprint("| " * len(stack))
+ assert stack.pop() == node
+
+ else:
+ self.debugprint(f"{lead}{node}")
+
+ if visitor_fn:
+ visitor_fn(node, stack, debugprint=self.debugprint)
+
+ def debugprint(self, s, **kwargs):
+ if self._debug:
+ print(s, file=sys.stderr, **kwargs)
+
+
+# [ RUN MODES ] ###############################################################
+
+
+class MuttCommands:
+ """
+ Stupid class to interface writing out Mutt commands. This is quite a hack
+ to deal with the fact that Mutt runs "push" commands in reverse order, so
+ all of a sudden, things become very complicated when mixing with "real"
+ commands.
+
+ Hence we keep two sets of commands, and one set of pushes. Commands are
+ added to the first until a push is added, after which commands are added to
+ the second set of commands.
+
+ On flush(), the first set is printed, followed by the pushes in reverse,
+ and then the second set is printed. All 3 sets are then cleared.
+ """
+
+ def __init__(self, out_f=sys.stdout, *, debug=False):
+ self._cmd1, self._push, self._cmd2 = [], [], []
+ self._out_f = out_f
+ self._debug = debug
+
+ def cmd(self, s):
+ self.debugprint(s)
+ if self._push:
+ self._cmd2.append(s)
+ else:
+ self._cmd1.append(s)
+
+ def push(self, s):
+ s = s.replace('"', '"')
+ s = f'push "{s}"'
+ self.debugprint(s)
+ self._push.insert(0, s)
+
+ def flush(self):
+ print(
+ "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
+ )
+ self._cmd1, self._push, self._cmd2 = [], [], []
+
+ def debugprint(self, s, **kwargs):
+ if self._debug:
+ print(s, file=sys.stderr, **kwargs)
+
+
+def do_setup(
+ extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
+):
+ extensions = extensions or []
+ temppath = temppath or pathlib.Path(
+ tempfile.mkstemp(prefix="muttmdwn-")[1]
+ )
+ cmds = MuttCommands(out_f, debug=debug_commands)
+
+ editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
+ if extensions:
+ editor = f'{editor} --extensions {",".join(extensions)}'
+
+ cmds.cmd('set my_editor="$editor"')
+ cmds.cmd('set my_edit_headers="$edit_headers"')
+ cmds.cmd(f'set editor="{editor}"')
+ cmds.cmd("unset edit_headers")
+ cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
+ cmds.push("<first-entry><edit-file>")
+ cmds.flush()
+
+
+def do_massage(
+ maildraft,
+ cmdpath,
+ *,
+ extensions=None,
+ converter=convert_markdown_to_html,
+ debug_commands=False,
+ debug_walk=False,
+):
+ # Here's the big picture: we're being invoked as the editor on the email
+ # draft, and whatever commands we write to the file given as cmdpath will
+ # be run by the second source command in the macro definition.
+
+ with open(cmdpath, "w") as cmd_f:
+ # Let's start by cleaning up what the setup did (see above), i.e. we
+ # restore the $editor and $edit_headers variables, and also unset the
+ # variable used to identify the command file we're currently writing
+ # to.
+ cmds = MuttCommands(cmd_f, debug=debug_commands)
+ cmds.cmd('set editor="$my_editor"')
+ cmds.cmd('set edit_headers="$my_edit_headers"')
+ cmds.cmd("unset my_editor")
+ cmds.cmd("unset my_edit_headers")
+
+ # let's flush those commands, as there'll be a lot of pushes from now
+ # on, which need to be run in reverse order
+ cmds.flush()
+
+ extensions = extensions.split(",") if extensions else []
+ tree = converter(maildraft, extensions=extensions)
+
+ mimetree = MIMETreeDFWalker(debug=args.debug_walk)
+
+ def visitor_fn(item, stack, *, debugprint=None):
+ """
+ Visitor function called for every node (part) of the MIME tree,
+ depth-first, and responsible for telling NeoMutt how to assemble
+ the tree.
+ """
+ if isinstance(item, Part):
+ # We've hit a leaf-node, i.e. an alternative or a related part
+ # with actual content.
+
+ # If the part is not an original part, i.e. doesn't already
+ # exist, we must first add it.
+ if not item.orig:
+ cmds.push(f"<attach-file>{item.path}<enter>")
+ cmds.push("<toggle-unlink><toggle-disposition>")
+ if item.cid:
+ cmds.push(
+ f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>"
+ )
+
+ # If the item (including the original) comes with a
+ # description, then we might just as well update the NeoMutt
+ # tree now:
+ if item.desc:
+ cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
+
+ # Finally, tag the entry that we just processed, so that when
+ # we're done at this level, as we walk up the stack, the items
+ # to be grouped will already be tagged and ready.
+ cmds.push("<tag-entry>")
+
+ elif isinstance(item, Multipart):
+ # This node has children, but we already visited them (see
+ # above), and so they have been tagged in NeoMutt's compose
+ # window. Now it's just a matter of telling NeoMutt to do the
+ # appropriate grouping:
+ if item.subtype == "alternative":
+ cmds.push("<group-alternatives>")
+ elif item.subtype == "relative":
+ cmds.push("<group-related>")
+ elif item.subtype == "multilingual":
+ cmds.push("<group-multilingual>")
+
+ # Again, if there is a description, we might just as well:
+ if item.desc:
+ cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
+
+ # Finally, if we're at non-root level, tag the new container,
+ # as it might itself be part of a container, to be processed
+ # one level up:
+ if stack:
+ cmds.push("<tag-entry>")
+
+ else:
+ # We should never get here
+ assert not "is valid part"
+
+ # -----------------
+ # End of visitor_fn
+
+ # Let's walk the tree and visit every node with our fancy visitor
+ # function
+ mimetree.walk(tree, visitor_fn=visitor_fn)
+
+ # Finally, cleanup. Since we're responsible for removing the temporary
+ # file, how's this for a little hack?
+ cmds.cmd(f"source 'rm -f {args.cmdpath}|'")
+ cmds.cmd("unset my_mdwn_postprocess_cmd_file")
+ cmds.flush()
+
+
+# [ CLI ENTRY ] ###############################################################
+
+if __name__ == "__main__":
+ args = parse_cli_args()
+
+ if args.mode == "setup":
+ do_setup(args.extensions, debug_commands=args.debug_commands)
+
+ elif args.mode == "massage":
+ do_massage(
+ args.MAILDRAFT,
+ args.cmdpath,
+ extensions=args.extensions,
+ debug_commands=args.debug_commands,
+ debug_walk=args.debug_walk,
+ )
+
+
+# [ TESTS ] ###################################################################
+
+try:
+ import pytest
+
+ class Tests:
+ @pytest.fixture
+ def const1(self):
+ return "CONSTANT STRING 1"
+
+ @pytest.fixture
+ def const2(self):
+ return "CONSTANT STRING 2"
+
+ # NOTE: tests using the capsys fixture must specify sys.stdout to the
+ # functions they call, else old stdout is used and not captured
+
+ def test_MuttCommands_cmd(self, const1, const2, capsys):
+ "Assert order of commands"
+ cmds = MuttCommands(out_f=sys.stdout)
+ cmds.cmd(const1)
+ cmds.cmd(const2)
+ cmds.flush()
+ captured = capsys.readouterr()
+ assert captured.out == "\n".join((const1, const2, ""))
+
+ def test_MuttCommands_push(self, const1, const2, capsys):
+ "Assert reverse order of pushes"
+ cmds = MuttCommands(out_f=sys.stdout)
+ cmds.push(const1)
+ cmds.push(const2)
+ cmds.flush()
+ captured = capsys.readouterr()
+ assert (
+ captured.out
+ == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
+ )
+
+ def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
+ "Assert reverse order of pushes"
+ cmds = MuttCommands(out_f=sys.stdout)
+ lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
+ for i in range(2):
+ cmds.cmd(lines[4 * i + 0])
+ cmds.cmd(lines[4 * i + 1])
+ cmds.push(lines[4 * i + 2])
+ cmds.push(lines[4 * i + 3])
+ cmds.flush()
+
+ captured = capsys.readouterr()
+ lines_out = captured.out.splitlines()
+ assert lines[0] in lines_out[0]
+ assert lines[1] in lines_out[1]
+ assert lines[7] in lines_out[2]
+ assert lines[6] in lines_out[3]
+ assert lines[3] in lines_out[4]
+ assert lines[2] in lines_out[5]
+ assert lines[4] in lines_out[6]
+ assert lines[5] in lines_out[7]
+
+ @pytest.fixture
+ def basic_mime_tree(self):
+ return Multipart(
+ "related",
+ children=[
+ Multipart(
+ "alternative",
+ children=[
+ Part("text", "plain", "part.txt", desc="Plain"),
+ Part("text", "html", "part.html", desc="HTML"),
+ ],
+ desc="Alternative",
+ ),
+ Part(
+ "text", "png", "logo.png", cid="logo.png", desc="Logo"
+ ),
+ ],
+ desc="Related",
+ )
+
+ def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
+ mimetree = MIMETreeDFWalker()
+
+ items = []
+
+ def visitor_fn(item, stack, debugprint):
+ items.append((item, len(stack)))
+
+ mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
+ assert len(items) == 5
+ assert items[0][0].subtype == "plain"
+ assert items[0][1] == 2
+ assert items[1][0].subtype == "html"
+ assert items[1][1] == 2
+ assert items[2][0].subtype == "alternative"
+ assert items[2][1] == 1
+ assert items[3][0].subtype == "png"
+ assert items[3][1] == 1
+ assert items[4][0].subtype == "related"
+ assert items[4][1] == 0
+
+ def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
+ mimetree = MIMETreeDFWalker()
+
+ items = []
+
+ def visitor_fn(item, stack, debugprint):
+ items.append(item)
+
+ mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
+ assert items[-1].subtype == "mixed"
+
+ def test_MIMETreeDFWalker_visitor_in_constructor(
+ self, basic_mime_tree
+ ):
+ items = []
+
+ def visitor_fn(item, stack, debugprint):
+ items.append(item)
+
+ mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
+ mimetree.walk(basic_mime_tree)
+ assert len(items) == 5
+
+ def test_do_setup_no_extensions(self, const1, capsys):
+ "Assert basics about the setup command output"
+ do_setup(temppath=const1, out_f=sys.stdout)
+ captout = capsys.readouterr()
+ lines = captout.out.splitlines()
+ assert lines[2].endswith(f'{const1}"')
+ assert lines[4].endswith(const1)
+ assert "first-entry" in lines[-1]
+ assert "edit-file" in lines[-1]
+
+ def test_do_setup_extensions(self, const1, const2, capsys):
+ "Assert that extensions are passed to editor"
+ do_setup(
+ temppath=const1, extensions=[const2, const1], out_f=sys.stdout
+ )
+ captout = capsys.readouterr()
+ lines = captout.out.splitlines()
+ # assert comma-separated list of extensions passed
+ assert lines[2].endswith(f'{const2},{const1}"')
+ assert lines[4].endswith(const1)
+
+except ImportError:
+ pass