All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
3 # NeoMutt helper script to create multipart/* emails with Markdown → HTML
4 # alternative conversion, and handling of inline images, using NeoMutt's
5 # ability to manually craft MIME trees, but automating this process.
8 # neomuttrc (needs to be a single line):
10 # <enter-command> source '$my_confdir/buildmimetree.py setup|'<enter>\
11 # <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
12 # " "Convert message into a modern MIME tree with inline images"
14 # (Yes, we need to call source twice, as mutt only starts to process output
15 # from a source command when the command exits, and since we need to react
16 # to the output, we need to be invoked again, using a $my_ variable to pass
25 # - Pygments, if installed, then syntax highlighting is enabled
28 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
30 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
31 # Released under the GPL-2+ licence, just like Mutt itself.
39 from collections import namedtuple
42 def parse_cli_args(*args, **kwargs):
43 parser = argparse.ArgumentParser(
45 "NeoMutt helper to turn text/markdown email parts "
46 "into full-fledged MIME trees"
50 "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
51 "Released under the MIT licence"
54 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
55 parser_setup = subp.add_parser("setup", help="Setup phase")
56 parser_massage = subp.add_parser("massage", help="Massaging phase")
58 parser_setup.add_argument(
61 help="Turn on debug logging of commands generated to stderr",
64 parser_setup.add_argument(
72 help="Markdown extension to add to the list of extensions use",
75 parser_massage.add_argument(
78 help="Turn on debug logging of commands generated to stderr",
81 parser_massage.add_argument(
84 help="Turn on debugging to stderr of the MIME tree walk",
87 parser_massage.add_argument(
92 help="Markdown extension to use (comma-separated list)",
95 parser_massage.add_argument(
96 "--write-commands-to",
99 help="Temporary file path to write commands to",
102 parser_massage.add_argument(
105 help="If provided, the script is invoked as editor on the mail draft",
108 return parser.parse_args(*args, **kwargs)
111 # [ PARTS GENERATION ] ########################################################
117 ["type", "subtype", "path", "desc", "cid", "orig"],
118 defaults=[None, None, False],
122 ret = f"<{self.type}/{self.subtype}>"
124 ret = f"{ret} cid:{self.cid}"
126 ret = f"{ret} ORIGINAL"
131 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
134 return f"<multipart/{self.subtype}> children={len(self.children)}"
137 def convert_markdown_to_html(maildraft, *, extensions=None):
138 draftpath = pathlib.Path(maildraft)
140 "text", "plain", draftpath, "Plain-text version", orig=True
143 with open(draftpath, "r", encoding="utf-8") as textmarkdown:
144 text = textmarkdown.read()
146 mdwn = markdown.Markdown(extensions=extensions)
147 html = mdwn.convert(text)
149 htmlpath = draftpath.with_suffix(".html")
150 htmlpart = Part("text", "html", htmlpath, "HTML version")
153 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
160 "/usr/share/doc/neomutt/logo/neomutt-256.png",
170 [textpart, htmlpart],
171 "Group of alternative content",
175 "Group of related content",
179 class MIMETreeDFWalker:
180 def __init__(self, *, visitor_fn=None, debug=False):
181 self._visitor_fn = visitor_fn
184 def walk(self, root, *, visitor_fn=None):
186 Recursive function to implement a depth-dirst walk of the MIME-tree
190 if isinstance(root, list):
191 root = Multipart("mixed", children=root)
196 visitor_fn=visitor_fn or self._visitor_fn,
199 def _walk(self, node, *, stack, visitor_fn):
200 # Let's start by enumerating the parts at the current level. At the
201 # root level, stack will be the empty list, and we expect a multipart/*
202 # container at this level. Later, e.g. within a mutlipart/alternative
203 # container, the subtree will just be the alternative parts, while the
204 # top of the stack will be the multipart/alternative container, which
205 # we will process after the following loop.
207 lead = f"{'| '*len(stack)}|-"
208 if isinstance(node, Multipart):
210 f"{lead}{node} parents={[s.subtype for s in stack]}"
213 # Depth-first, so push the current container onto the stack,
216 self.debugprint("| " * (len(stack) + 1))
217 for child in node.children:
221 visitor_fn=visitor_fn,
223 self.debugprint("| " * len(stack))
224 assert stack.pop() == node
227 self.debugprint(f"{lead}{node}")
230 visitor_fn(node, stack, debugprint=self.debugprint)
232 def debugprint(self, s, **kwargs):
234 print(s, file=sys.stderr, **kwargs)
237 # [ RUN MODES ] ###############################################################
242 Stupid class to interface writing out Mutt commands. This is quite a hack
243 to deal with the fact that Mutt runs "push" commands in reverse order, so
244 all of a sudden, things become very complicated when mixing with "real"
247 Hence we keep two sets of commands, and one set of pushes. Commands are
248 added to the first until a push is added, after which commands are added to
249 the second set of commands.
251 On flush(), the first set is printed, followed by the pushes in reverse,
252 and then the second set is printed. All 3 sets are then cleared.
255 def __init__(self, out_f=sys.stdout, *, debug=False):
256 self._cmd1, self._push, self._cmd2 = [], [], []
268 s = s.replace('"', '"')
271 self._push.insert(0, s)
275 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
277 self._cmd1, self._push, self._cmd2 = [], [], []
279 def debugprint(self, s, **kwargs):
281 print(s, file=sys.stderr, **kwargs)
285 extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
287 extensions = extensions or []
288 temppath = temppath or pathlib.Path(
289 tempfile.mkstemp(prefix="muttmdwn-")[1]
291 cmds = MuttCommands(out_f, debug=debug_commands)
293 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
295 editor = f'{editor} --extensions {",".join(extensions)}'
297 cmds.cmd('set my_editor="$editor"')
298 cmds.cmd('set my_edit_headers="$edit_headers"')
299 cmds.cmd(f'set editor="{editor}"')
300 cmds.cmd("unset edit_headers")
301 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
302 cmds.push("<first-entry><edit-file>")
311 converter=convert_markdown_to_html,
312 debug_commands=False,
315 # Here's the big picture: we're being invoked as the editor on the email
316 # draft, and whatever commands we write to the file given as cmdpath will
317 # be run by the second source command in the macro definition.
319 with open(cmdpath, "w") as cmd_f:
320 # Let's start by cleaning up what the setup did (see above), i.e. we
321 # restore the $editor and $edit_headers variables, and also unset the
322 # variable used to identify the command file we're currently writing
324 cmds = MuttCommands(cmd_f, debug=debug_commands)
325 cmds.cmd('set editor="$my_editor"')
326 cmds.cmd('set edit_headers="$my_edit_headers"')
327 cmds.cmd("unset my_editor")
328 cmds.cmd("unset my_edit_headers")
330 # let's flush those commands, as there'll be a lot of pushes from now
331 # on, which need to be run in reverse order
334 extensions = extensions.split(",") if extensions else []
335 tree = converter(maildraft, extensions=extensions)
337 mimetree = MIMETreeDFWalker(debug=args.debug_walk)
339 def visitor_fn(item, stack, *, debugprint=None):
341 Visitor function called for every node (part) of the MIME tree,
342 depth-first, and responsible for telling NeoMutt how to assemble
345 if isinstance(item, Part):
346 # We've hit a leaf-node, i.e. an alternative or a related part
347 # with actual content.
349 # If the part is not an original part, i.e. doesn't already
350 # exist, we must first add it.
352 cmds.push(f"<attach-file>{item.path}<enter>")
353 cmds.push("<toggle-unlink><toggle-disposition>")
356 f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>"
359 # If the item (including the original) comes with a
360 # description, then we might just as well update the NeoMutt
363 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
365 # Finally, tag the entry that we just processed, so that when
366 # we're done at this level, as we walk up the stack, the items
367 # to be grouped will already be tagged and ready.
368 cmds.push("<tag-entry>")
370 elif isinstance(item, Multipart):
371 # This node has children, but we already visited them (see
372 # above), and so they have been tagged in NeoMutt's compose
373 # window. Now it's just a matter of telling NeoMutt to do the
374 # appropriate grouping:
375 if item.subtype == "alternative":
376 cmds.push("<group-alternatives>")
377 elif item.subtype == "relative":
378 cmds.push("<group-related>")
379 elif item.subtype == "multilingual":
380 cmds.push("<group-multilingual>")
382 # Again, if there is a description, we might just as well:
384 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
386 # Finally, if we're at non-root level, tag the new container,
387 # as it might itself be part of a container, to be processed
390 cmds.push("<tag-entry>")
393 # We should never get here
394 assert not "is valid part"
399 # Let's walk the tree and visit every node with our fancy visitor
401 mimetree.walk(tree, visitor_fn=visitor_fn)
403 # Finally, cleanup. Since we're responsible for removing the temporary
404 # file, how's this for a little hack?
405 cmds.cmd(f"source 'rm -f {args.cmdpath}|'")
406 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
410 # [ CLI ENTRY ] ###############################################################
412 if __name__ == "__main__":
413 args = parse_cli_args()
415 if args.mode == "setup":
416 do_setup(args.extensions, debug_commands=args.debug_commands)
418 elif args.mode == "massage":
422 extensions=args.extensions,
423 debug_commands=args.debug_commands,
424 debug_walk=args.debug_walk,
428 # [ TESTS ] ###################################################################
436 return "CONSTANT STRING 1"
440 return "CONSTANT STRING 2"
442 # NOTE: tests using the capsys fixture must specify sys.stdout to the
443 # functions they call, else old stdout is used and not captured
445 def test_MuttCommands_cmd(self, const1, const2, capsys):
446 "Assert order of commands"
447 cmds = MuttCommands(out_f=sys.stdout)
451 captured = capsys.readouterr()
452 assert captured.out == "\n".join((const1, const2, ""))
454 def test_MuttCommands_push(self, const1, const2, capsys):
455 "Assert reverse order of pushes"
456 cmds = MuttCommands(out_f=sys.stdout)
460 captured = capsys.readouterr()
463 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
466 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
467 "Assert reverse order of pushes"
468 cmds = MuttCommands(out_f=sys.stdout)
469 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
471 cmds.cmd(lines[4 * i + 0])
472 cmds.cmd(lines[4 * i + 1])
473 cmds.push(lines[4 * i + 2])
474 cmds.push(lines[4 * i + 3])
477 captured = capsys.readouterr()
478 lines_out = captured.out.splitlines()
479 assert lines[0] in lines_out[0]
480 assert lines[1] in lines_out[1]
481 assert lines[7] in lines_out[2]
482 assert lines[6] in lines_out[3]
483 assert lines[3] in lines_out[4]
484 assert lines[2] in lines_out[5]
485 assert lines[4] in lines_out[6]
486 assert lines[5] in lines_out[7]
489 def basic_mime_tree(self):
496 Part("text", "plain", "part.txt", desc="Plain"),
497 Part("text", "html", "part.html", desc="HTML"),
502 "text", "png", "logo.png", cid="logo.png", desc="Logo"
508 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
509 mimetree = MIMETreeDFWalker()
513 def visitor_fn(item, stack, debugprint):
514 items.append((item, len(stack)))
516 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
517 assert len(items) == 5
518 assert items[0][0].subtype == "plain"
519 assert items[0][1] == 2
520 assert items[1][0].subtype == "html"
521 assert items[1][1] == 2
522 assert items[2][0].subtype == "alternative"
523 assert items[2][1] == 1
524 assert items[3][0].subtype == "png"
525 assert items[3][1] == 1
526 assert items[4][0].subtype == "related"
527 assert items[4][1] == 0
529 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
530 mimetree = MIMETreeDFWalker()
534 def visitor_fn(item, stack, debugprint):
537 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
538 assert items[-1].subtype == "mixed"
540 def test_MIMETreeDFWalker_visitor_in_constructor(
541 self, basic_mime_tree
545 def visitor_fn(item, stack, debugprint):
548 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
549 mimetree.walk(basic_mime_tree)
550 assert len(items) == 5
552 def test_do_setup_no_extensions(self, const1, capsys):
553 "Assert basics about the setup command output"
554 do_setup(temppath=const1, out_f=sys.stdout)
555 captout = capsys.readouterr()
556 lines = captout.out.splitlines()
557 assert lines[2].endswith(f'{const1}"')
558 assert lines[4].endswith(const1)
559 assert "first-entry" in lines[-1]
560 assert "edit-file" in lines[-1]
562 def test_do_setup_extensions(self, const1, const2, capsys):
563 "Assert that extensions are passed to editor"
565 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
567 captout = capsys.readouterr()
568 lines = captout.out.splitlines()
569 # assert comma-separated list of extensions passed
570 assert lines[2].endswith(f'{const2},{const1}"')
571 assert lines[4].endswith(const1)