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_setup.add_argument(
78 help="Generate command(s) to send the message after processing",
81 parser_massage.add_argument(
84 help="Turn on debug logging of commands generated to stderr",
87 parser_massage.add_argument(
90 help="Turn on debugging to stderr of the MIME tree walk",
93 parser_massage.add_argument(
98 help="Markdown extension to use (comma-separated list)",
101 parser_massage.add_argument(
102 "--write-commands-to",
105 help="Temporary file path to write commands to",
108 parser_massage.add_argument(
111 help="If provided, the script is invoked as editor on the mail draft",
114 return parser.parse_args(*args, **kwargs)
117 # [ PARTS GENERATION ] ########################################################
123 ["type", "subtype", "path", "desc", "cid", "orig"],
124 defaults=[None, None, False],
128 ret = f"<{self.type}/{self.subtype}>"
130 ret = f"{ret} cid:{self.cid}"
132 ret = f"{ret} ORIGINAL"
137 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
140 return f"<multipart/{self.subtype}> children={len(self.children)}"
144 def convert_markdown_to_html(
145 origtext, draftpath, *, filewriter_fn=None, extensions=None
147 mdwn = markdown.Markdown(extensions=extensions)
149 if not filewriter_fn:
151 def filewriter_fn(path, content, mode="w", **kwargs):
152 with open(path, mode, **kwargs) as out_f:
155 filewriter_fn(draftpath, origtext, encoding="utf-8")
157 "text", "plain", draftpath, "Plain-text version", orig=True
160 htmltext = mdwn.convert(origtext)
162 htmlpath = draftpath.with_suffix(".html")
164 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
166 htmlpart = Part("text", "html", htmlpath, "HTML version")
171 "/usr/share/doc/neomutt/logo/neomutt-256.png",
181 [textpart, htmlpart],
182 "Group of alternative content",
186 "Group of related content",
190 class MIMETreeDFWalker:
191 def __init__(self, *, visitor_fn=None, debug=False):
192 self._visitor_fn = visitor_fn
195 def walk(self, root, *, visitor_fn=None):
197 Recursive function to implement a depth-dirst walk of the MIME-tree
201 if isinstance(root, list):
202 root = Multipart("mixed", children=root)
207 visitor_fn=visitor_fn or self._visitor_fn,
210 def _walk(self, node, *, stack, visitor_fn):
211 # Let's start by enumerating the parts at the current level. At the
212 # root level, stack will be the empty list, and we expect a multipart/*
213 # container at this level. Later, e.g. within a mutlipart/alternative
214 # container, the subtree will just be the alternative parts, while the
215 # top of the stack will be the multipart/alternative container, which
216 # we will process after the following loop.
218 lead = f"{'| '*len(stack)}|-"
219 if isinstance(node, Multipart):
221 f"{lead}{node} parents={[s.subtype for s in stack]}"
224 # Depth-first, so push the current container onto the stack,
227 self.debugprint("| " * (len(stack) + 1))
228 for child in node.children:
232 visitor_fn=visitor_fn,
234 self.debugprint("| " * len(stack))
235 assert stack.pop() == node
238 self.debugprint(f"{lead}{node}")
241 visitor_fn(node, stack, debugprint=self.debugprint)
243 def debugprint(self, s, **kwargs):
245 print(s, file=sys.stderr, **kwargs)
248 # [ RUN MODES ] ###############################################################
253 Stupid class to interface writing out Mutt commands. This is quite a hack
254 to deal with the fact that Mutt runs "push" commands in reverse order, so
255 all of a sudden, things become very complicated when mixing with "real"
258 Hence we keep two sets of commands, and one set of pushes. Commands are
259 added to the first until a push is added, after which commands are added to
260 the second set of commands.
262 On flush(), the first set is printed, followed by the pushes in reverse,
263 and then the second set is printed. All 3 sets are then cleared.
266 def __init__(self, out_f=sys.stdout, *, debug=False):
267 self._cmd1, self._push, self._cmd2 = [], [], []
279 s = s.replace('"', '"')
282 self._push.insert(0, s)
286 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
288 self._cmd1, self._push, self._cmd2 = [], [], []
290 def debugprint(self, s, **kwargs):
292 print(s, file=sys.stderr, **kwargs)
296 extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
298 extensions = extensions or []
299 temppath = temppath or pathlib.Path(
300 tempfile.mkstemp(prefix="muttmdwn-")[1]
302 cmds = MuttCommands(out_f, debug=debug_commands)
304 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
306 editor = f'{editor} --extensions {",".join(extensions)}'
308 editor = f'{editor} --debug-commands'
310 cmds.cmd('set my_editor="$editor"')
311 cmds.cmd('set my_edit_headers="$edit_headers"')
312 cmds.cmd(f'set editor="{editor}"')
313 cmds.cmd("unset edit_headers")
314 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
315 cmds.push("<first-entry><edit-file>")
325 converter=convert_markdown_to_html,
326 debug_commands=False,
329 # Here's the big picture: we're being invoked as the editor on the email
330 # draft, and whatever commands we write to the file given as cmdpath will
331 # be run by the second source command in the macro definition.
333 # Let's start by cleaning up what the setup did (see above), i.e. we
334 # restore the $editor and $edit_headers variables, and also unset the
335 # variable used to identify the command file we're currently writing
337 cmds = MuttCommands(cmd_f, debug=debug_commands)
338 cmds.cmd('set editor="$my_editor"')
339 cmds.cmd('set edit_headers="$my_edit_headers"')
340 cmds.cmd("unset my_editor")
341 cmds.cmd("unset my_edit_headers")
343 # let's flush those commands, as there'll be a lot of pushes from now
344 # on, which need to be run in reverse order
347 extensions = extensions.split(",") if extensions else []
348 tree = converter(draft_f.read(), draftpath, extensions=extensions)
350 mimetree = MIMETreeDFWalker(debug=debug_walk)
352 def visitor_fn(item, stack, *, debugprint=None):
354 Visitor function called for every node (part) of the MIME tree,
355 depth-first, and responsible for telling NeoMutt how to assemble
358 if isinstance(item, Part):
359 # We've hit a leaf-node, i.e. an alternative or a related part
360 # with actual content.
364 # The original source already exists in the NeoMutt tree, but
365 # the underlying file may have been modified, so we need to
366 # update the encoding, but that's it:
367 cmds.push("<update-encoding>")
369 # … whereas all other parts need to be added, and they're all
370 # considered to be temporary and inline:
371 cmds.push(f"<attach-file>{item.path}<enter>")
372 cmds.push("<toggle-unlink><toggle-disposition>")
374 # If the item (including the original) comes with additional
375 # information, then we might just as well update the NeoMutt
378 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
380 elif isinstance(item, Multipart):
381 # This node has children, but we already visited them (see
382 # above), and so they have been tagged in NeoMutt's compose
383 # window. Now it's just a matter of telling NeoMutt to do the
384 # appropriate grouping:
385 if item.subtype == "alternative":
386 cmds.push("<group-alternatives>")
387 elif item.subtype == "relative":
388 cmds.push("<group-related>")
389 elif item.subtype == "multilingual":
390 cmds.push("<group-multilingual>")
393 # We should never get here
394 assert not "is valid part"
396 # If the item has a description, we might just as well add it
398 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
400 # Finally, if we're at non-root level, tag the new container,
401 # as it might itself be part of a container, to be processed
404 cmds.push("<tag-entry>")
409 # Let's walk the tree and visit every node with our fancy visitor
411 mimetree.walk(tree, visitor_fn=visitor_fn)
413 # Finally, cleanup. Since we're responsible for removing the temporary
414 # file, how's this for a little hack?
416 filename = cmd_f.name
417 except AttributeError:
418 filename = "pytest_internal_file"
419 cmds.cmd(f"source 'rm -f {filename}|'")
420 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
424 # [ CLI ENTRY ] ###############################################################
426 if __name__ == "__main__":
427 args = parse_cli_args()
429 if args.mode == "setup":
430 if args.send_message:
431 raise NotImplementedError()
433 do_setup(args.extensions, debug_commands=args.debug_commands)
435 elif args.mode == "massage":
436 with open(args.MAILDRAFT, "r") as draft_f, open(
441 pathlib.Path(args.MAILDRAFT),
443 extensions=args.extensions,
444 debug_commands=args.debug_commands,
445 debug_walk=args.debug_walk,
449 # [ TESTS ] ###################################################################
453 from io import StringIO
458 return "CONSTANT STRING 1"
462 return "CONSTANT STRING 2"
464 # NOTE: tests using the capsys fixture must specify sys.stdout to the
465 # functions they call, else old stdout is used and not captured
467 def test_MuttCommands_cmd(self, const1, const2, capsys):
468 "Assert order of commands"
469 cmds = MuttCommands(out_f=sys.stdout)
473 captured = capsys.readouterr()
474 assert captured.out == "\n".join((const1, const2, ""))
476 def test_MuttCommands_push(self, const1, const2, capsys):
477 "Assert reverse order of pushes"
478 cmds = MuttCommands(out_f=sys.stdout)
482 captured = capsys.readouterr()
485 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
488 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
489 "Assert reverse order of pushes"
490 cmds = MuttCommands(out_f=sys.stdout)
491 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
493 cmds.cmd(lines[4 * i + 0])
494 cmds.cmd(lines[4 * i + 1])
495 cmds.push(lines[4 * i + 2])
496 cmds.push(lines[4 * i + 3])
499 captured = capsys.readouterr()
500 lines_out = captured.out.splitlines()
501 assert lines[0] in lines_out[0]
502 assert lines[1] in lines_out[1]
503 assert lines[7] in lines_out[2]
504 assert lines[6] in lines_out[3]
505 assert lines[3] in lines_out[4]
506 assert lines[2] in lines_out[5]
507 assert lines[4] in lines_out[6]
508 assert lines[5] in lines_out[7]
511 def basic_mime_tree(self):
525 Part("text", "html", "part.html", desc="HTML"),
530 "text", "png", "logo.png", cid="logo.png", desc="Logo"
536 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
537 mimetree = MIMETreeDFWalker()
541 def visitor_fn(item, stack, debugprint):
542 items.append((item, len(stack)))
544 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
545 assert len(items) == 5
546 assert items[0][0].subtype == "plain"
547 assert items[0][1] == 2
548 assert items[1][0].subtype == "html"
549 assert items[1][1] == 2
550 assert items[2][0].subtype == "alternative"
551 assert items[2][1] == 1
552 assert items[3][0].subtype == "png"
553 assert items[3][1] == 1
554 assert items[4][0].subtype == "relative"
555 assert items[4][1] == 0
557 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
558 mimetree = MIMETreeDFWalker()
561 def visitor_fn(item, stack, debugprint):
564 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
565 assert items[-1].subtype == "mixed"
567 def test_MIMETreeDFWalker_visitor_in_constructor(
568 self, basic_mime_tree
572 def visitor_fn(item, stack, debugprint):
575 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
576 mimetree.walk(basic_mime_tree)
577 assert len(items) == 5
579 def test_do_setup_no_extensions(self, const1, capsys):
580 "Assert basics about the setup command output"
581 do_setup(temppath=const1, out_f=sys.stdout)
582 captout = capsys.readouterr()
583 lines = captout.out.splitlines()
584 assert lines[2].endswith(f'{const1}"')
585 assert lines[4].endswith(const1)
586 assert "first-entry" in lines[-1]
587 assert "edit-file" in lines[-1]
589 def test_do_setup_extensions(self, const1, const2, capsys):
590 "Assert that extensions are passed to editor"
592 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
594 captout = capsys.readouterr()
595 lines = captout.out.splitlines()
596 # assert comma-separated list of extensions passed
597 assert lines[2].endswith(f'{const2},{const1}"')
598 assert lines[4].endswith(const1)
601 def string_io(self, const1, text=None):
602 return StringIO(text or const1)
604 def test_do_massage_basic(self, const1, string_io, capsys):
605 def converter(drafttext, draftpath, extensions):
606 return Part("text", "plain", draftpath, orig=True)
615 captured = capsys.readouterr()
616 lines = captured.out.splitlines()
617 assert '="$my_editor"' in lines.pop(0)
618 assert '="$my_edit_headers"' in lines.pop(0)
619 assert "unset my_editor" == lines.pop(0)
620 assert "unset my_edit_headers" == lines.pop(0)
621 assert "update-encoding" in lines.pop(0)
622 assert "source 'rm -f " in lines.pop(0)
623 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
625 def test_do_massage_fulltree(
626 self, string_io, const1, basic_mime_tree, capsys
628 def converter(drafttext, draftpath, extensions):
629 return basic_mime_tree
638 captured = capsys.readouterr()
639 lines = captured.out.splitlines()[4:][::-1]
640 assert "Related" in lines.pop()
641 assert "group-related" in lines.pop()
642 assert "tag-entry" in lines.pop()
643 assert "Logo" in lines.pop()
644 assert "content-id" in lines.pop()
645 assert "toggle-unlink" in lines.pop()
646 assert "logo.png" in lines.pop()
647 assert "tag-entry" in lines.pop()
648 assert "Alternative" in lines.pop()
649 assert "group-alternatives" in lines.pop()
650 assert "tag-entry" in lines.pop()
651 assert "HTML" in lines.pop()
652 assert "toggle-unlink" in lines.pop()
653 assert "part.html" in lines.pop()
654 assert "tag-entry" in lines.pop()
655 assert "Plain" in lines.pop()