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 # Let's start by cleaning up what the setup did (see above), i.e. we
320 # restore the $editor and $edit_headers variables, and also unset the
321 # variable used to identify the command file we're currently writing
323 cmds = MuttCommands(cmd_f, debug=debug_commands)
324 cmds.cmd('set editor="$my_editor"')
325 cmds.cmd('set edit_headers="$my_edit_headers"')
326 cmds.cmd("unset my_editor")
327 cmds.cmd("unset my_edit_headers")
329 # let's flush those commands, as there'll be a lot of pushes from now
330 # on, which need to be run in reverse order
333 extensions = extensions.split(",") if extensions else []
334 tree = converter(maildraft, extensions=extensions)
336 mimetree = MIMETreeDFWalker(debug=debug_walk)
338 def visitor_fn(item, stack, *, debugprint=None):
340 Visitor function called for every node (part) of the MIME tree,
341 depth-first, and responsible for telling NeoMutt how to assemble
344 if isinstance(item, Part):
345 # We've hit a leaf-node, i.e. an alternative or a related part
346 # with actual content.
348 # If the part is not an original part, i.e. doesn't already
349 # exist, we must first add it.
351 cmds.push(f"<attach-file>{item.path}<enter>")
352 cmds.push("<toggle-unlink><toggle-disposition>")
354 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
356 # If the item (including the original) comes with a
357 # description, then we might just as well update the NeoMutt
360 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
362 elif isinstance(item, Multipart):
363 # This node has children, but we already visited them (see
364 # above), and so they have been tagged in NeoMutt's compose
365 # window. Now it's just a matter of telling NeoMutt to do the
366 # appropriate grouping:
367 if item.subtype == "alternative":
368 cmds.push("<group-alternatives>")
369 elif item.subtype == "relative":
370 cmds.push("<group-related>")
371 elif item.subtype == "multilingual":
372 cmds.push("<group-multilingual>")
374 # Again, if there is a description, we might just as well:
376 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
379 # We should never get here
380 assert not "is valid part"
382 # Finally, if we're at non-root level, tag the new container,
383 # as it might itself be part of a container, to be processed
386 cmds.push("<tag-entry>")
391 # Let's walk the tree and visit every node with our fancy visitor
393 mimetree.walk(tree, visitor_fn=visitor_fn)
395 # Finally, cleanup. Since we're responsible for removing the temporary
396 # file, how's this for a little hack?
398 filename = cmd_f.name
399 except AttributeError:
400 filename = "pytest_internal_file"
401 cmds.cmd(f"source 'rm -f {filename}|'")
402 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
406 # [ CLI ENTRY ] ###############################################################
408 if __name__ == "__main__":
409 args = parse_cli_args()
411 if args.mode == "setup":
412 do_setup(args.extensions, debug_commands=args.debug_commands)
414 elif args.mode == "massage":
415 with open(args.cmdpath, "w") as cmd_f:
419 extensions=args.extensions,
420 debug_commands=args.debug_commands,
421 debug_walk=args.debug_walk,
425 # [ TESTS ] ###################################################################
433 return "CONSTANT STRING 1"
437 return "CONSTANT STRING 2"
439 # NOTE: tests using the capsys fixture must specify sys.stdout to the
440 # functions they call, else old stdout is used and not captured
442 def test_MuttCommands_cmd(self, const1, const2, capsys):
443 "Assert order of commands"
444 cmds = MuttCommands(out_f=sys.stdout)
448 captured = capsys.readouterr()
449 assert captured.out == "\n".join((const1, const2, ""))
451 def test_MuttCommands_push(self, const1, const2, capsys):
452 "Assert reverse order of pushes"
453 cmds = MuttCommands(out_f=sys.stdout)
457 captured = capsys.readouterr()
460 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
463 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
464 "Assert reverse order of pushes"
465 cmds = MuttCommands(out_f=sys.stdout)
466 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
468 cmds.cmd(lines[4 * i + 0])
469 cmds.cmd(lines[4 * i + 1])
470 cmds.push(lines[4 * i + 2])
471 cmds.push(lines[4 * i + 3])
474 captured = capsys.readouterr()
475 lines_out = captured.out.splitlines()
476 assert lines[0] in lines_out[0]
477 assert lines[1] in lines_out[1]
478 assert lines[7] in lines_out[2]
479 assert lines[6] in lines_out[3]
480 assert lines[3] in lines_out[4]
481 assert lines[2] in lines_out[5]
482 assert lines[4] in lines_out[6]
483 assert lines[5] in lines_out[7]
486 def basic_mime_tree(self):
500 Part("text", "html", "part.html", desc="HTML"),
505 "text", "png", "logo.png", cid="logo.png", desc="Logo"
511 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
512 mimetree = MIMETreeDFWalker()
516 def visitor_fn(item, stack, debugprint):
517 items.append((item, len(stack)))
519 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
520 assert len(items) == 5
521 assert items[0][0].subtype == "plain"
522 assert items[0][1] == 2
523 assert items[1][0].subtype == "html"
524 assert items[1][1] == 2
525 assert items[2][0].subtype == "alternative"
526 assert items[2][1] == 1
527 assert items[3][0].subtype == "png"
528 assert items[3][1] == 1
529 assert items[4][0].subtype == "relative"
530 assert items[4][1] == 0
532 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
533 mimetree = MIMETreeDFWalker()
537 def visitor_fn(item, stack, debugprint):
540 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
541 assert items[-1].subtype == "mixed"
543 def test_MIMETreeDFWalker_visitor_in_constructor(
544 self, basic_mime_tree
548 def visitor_fn(item, stack, debugprint):
551 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
552 mimetree.walk(basic_mime_tree)
553 assert len(items) == 5
555 def test_do_setup_no_extensions(self, const1, capsys):
556 "Assert basics about the setup command output"
557 do_setup(temppath=const1, out_f=sys.stdout)
558 captout = capsys.readouterr()
559 lines = captout.out.splitlines()
560 assert lines[2].endswith(f'{const1}"')
561 assert lines[4].endswith(const1)
562 assert "first-entry" in lines[-1]
563 assert "edit-file" in lines[-1]
565 def test_do_setup_extensions(self, const1, const2, capsys):
566 "Assert that extensions are passed to editor"
568 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
570 captout = capsys.readouterr()
571 lines = captout.out.splitlines()
572 # assert comma-separated list of extensions passed
573 assert lines[2].endswith(f'{const2},{const1}"')
574 assert lines[4].endswith(const1)
576 def test_do_massage_basic(self, const1, capsys):
577 def converter(maildraft, extensions):
578 return Part("text", "plain", "/dev/null", orig=True)
580 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
581 captured = capsys.readouterr()
585 set editor="$my_editor"
586 set edit_headers="$my_edit_headers"
588 unset my_edit_headers
589 source 'rm -f pytest_internal_file|'
590 unset my_mdwn_postprocess_cmd_file
596 def test_do_massage_fulltree(self, const1, basic_mime_tree, capsys):
597 def converter(maildraft, extensions):
598 return basic_mime_tree
600 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
601 captured = capsys.readouterr()
602 lines = captured.out.splitlines()[4:][::-1]
603 assert "Related" in lines.pop()
604 assert "group-related" in lines.pop()
605 assert "tag-entry" in lines.pop()
606 assert "Logo" in lines.pop()
607 assert "content-id" in lines.pop()
608 assert "toggle-unlink" in lines.pop()
609 assert "logo.png" in lines.pop()
610 assert "tag-entry" in lines.pop()
611 assert "Alternative" in lines.pop()
612 assert "group-alternatives" in lines.pop()
613 assert "tag-entry" in lines.pop()
614 assert "HTML" in lines.pop()
615 assert "toggle-unlink" in lines.pop()
616 assert "part.html" in lines.pop()
617 assert "tag-entry" in lines.pop()
618 assert "Plain" in lines.pop()