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)}"
143 def convert_markdown_to_html(maildraft, *, extensions=None):
144 draftpath = pathlib.Path(maildraft)
145 with open(draftpath, "r", encoding="utf-8") as textmarkdown:
146 text = textmarkdown.read()
148 mdwn = markdown.Markdown(extensions=extensions)
151 with open(draftpath, "w", encoding="utf-8") as textplain:
152 textplain.write(text)
154 "text", "plain", draftpath, "Plain-text version", orig=True
157 html = mdwn.convert(text)
158 htmlpath = draftpath.with_suffix(".html")
160 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
163 htmlpart = Part("text", "html", htmlpath, "HTML version")
168 "/usr/share/doc/neomutt/logo/neomutt-256.png",
178 [textpart, htmlpart],
179 "Group of alternative content",
183 "Group of related content",
187 class MIMETreeDFWalker:
188 def __init__(self, *, visitor_fn=None, debug=False):
189 self._visitor_fn = visitor_fn
192 def walk(self, root, *, visitor_fn=None):
194 Recursive function to implement a depth-dirst walk of the MIME-tree
198 if isinstance(root, list):
199 root = Multipart("mixed", children=root)
204 visitor_fn=visitor_fn or self._visitor_fn,
207 def _walk(self, node, *, stack, visitor_fn):
208 # Let's start by enumerating the parts at the current level. At the
209 # root level, stack will be the empty list, and we expect a multipart/*
210 # container at this level. Later, e.g. within a mutlipart/alternative
211 # container, the subtree will just be the alternative parts, while the
212 # top of the stack will be the multipart/alternative container, which
213 # we will process after the following loop.
215 lead = f"{'| '*len(stack)}|-"
216 if isinstance(node, Multipart):
218 f"{lead}{node} parents={[s.subtype for s in stack]}"
221 # Depth-first, so push the current container onto the stack,
224 self.debugprint("| " * (len(stack) + 1))
225 for child in node.children:
229 visitor_fn=visitor_fn,
231 self.debugprint("| " * len(stack))
232 assert stack.pop() == node
235 self.debugprint(f"{lead}{node}")
238 visitor_fn(node, stack, debugprint=self.debugprint)
240 def debugprint(self, s, **kwargs):
242 print(s, file=sys.stderr, **kwargs)
245 # [ RUN MODES ] ###############################################################
250 Stupid class to interface writing out Mutt commands. This is quite a hack
251 to deal with the fact that Mutt runs "push" commands in reverse order, so
252 all of a sudden, things become very complicated when mixing with "real"
255 Hence we keep two sets of commands, and one set of pushes. Commands are
256 added to the first until a push is added, after which commands are added to
257 the second set of commands.
259 On flush(), the first set is printed, followed by the pushes in reverse,
260 and then the second set is printed. All 3 sets are then cleared.
263 def __init__(self, out_f=sys.stdout, *, debug=False):
264 self._cmd1, self._push, self._cmd2 = [], [], []
276 s = s.replace('"', '"')
279 self._push.insert(0, s)
283 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
285 self._cmd1, self._push, self._cmd2 = [], [], []
287 def debugprint(self, s, **kwargs):
289 print(s, file=sys.stderr, **kwargs)
293 extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
295 extensions = extensions or []
296 temppath = temppath or pathlib.Path(
297 tempfile.mkstemp(prefix="muttmdwn-")[1]
299 cmds = MuttCommands(out_f, debug=debug_commands)
301 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
303 editor = f'{editor} --extensions {",".join(extensions)}'
305 cmds.cmd('set my_editor="$editor"')
306 cmds.cmd('set my_edit_headers="$edit_headers"')
307 cmds.cmd(f'set editor="{editor}"')
308 cmds.cmd("unset edit_headers")
309 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
310 cmds.push("<first-entry><edit-file>")
319 converter=convert_markdown_to_html,
320 debug_commands=False,
323 # Here's the big picture: we're being invoked as the editor on the email
324 # draft, and whatever commands we write to the file given as cmdpath will
325 # be run by the second source command in the macro definition.
327 # Let's start by cleaning up what the setup did (see above), i.e. we
328 # restore the $editor and $edit_headers variables, and also unset the
329 # variable used to identify the command file we're currently writing
331 cmds = MuttCommands(cmd_f, debug=debug_commands)
332 cmds.cmd('set editor="$my_editor"')
333 cmds.cmd('set edit_headers="$my_edit_headers"')
334 cmds.cmd("unset my_editor")
335 cmds.cmd("unset my_edit_headers")
337 # let's flush those commands, as there'll be a lot of pushes from now
338 # on, which need to be run in reverse order
341 extensions = extensions.split(",") if extensions else []
342 tree = converter(maildraft, extensions=extensions)
344 mimetree = MIMETreeDFWalker(debug=debug_walk)
346 def visitor_fn(item, stack, *, debugprint=None):
348 Visitor function called for every node (part) of the MIME tree,
349 depth-first, and responsible for telling NeoMutt how to assemble
352 if isinstance(item, Part):
353 # We've hit a leaf-node, i.e. an alternative or a related part
354 # with actual content.
358 # The original source already exists in the NeoMutt tree, but
359 # the underlying file may have been modified, so we need to
360 # update the encoding, but that's it:
361 cmds.push("<update-encoding>")
363 # … whereas all other parts need to be added, and they're all
364 # considered to be temporary and inline:
365 cmds.push(f"<attach-file>{item.path}<enter>")
366 cmds.push("<toggle-unlink><toggle-disposition>")
368 # If the item (including the original) comes with additional
369 # information, then we might just as well update the NeoMutt
372 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
374 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
376 elif isinstance(item, Multipart):
377 # This node has children, but we already visited them (see
378 # above), and so they have been tagged in NeoMutt's compose
379 # window. Now it's just a matter of telling NeoMutt to do the
380 # appropriate grouping:
381 if item.subtype == "alternative":
382 cmds.push("<group-alternatives>")
383 elif item.subtype == "relative":
384 cmds.push("<group-related>")
385 elif item.subtype == "multilingual":
386 cmds.push("<group-multilingual>")
388 # Again, if there is a description, we might just as well:
390 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
393 # We should never get here
394 assert not "is valid part"
396 # Finally, if we're at non-root level, tag the new container,
397 # as it might itself be part of a container, to be processed
400 cmds.push("<tag-entry>")
405 # Let's walk the tree and visit every node with our fancy visitor
407 mimetree.walk(tree, visitor_fn=visitor_fn)
409 # Finally, cleanup. Since we're responsible for removing the temporary
410 # file, how's this for a little hack?
412 filename = cmd_f.name
413 except AttributeError:
414 filename = "pytest_internal_file"
415 cmds.cmd(f"source 'rm -f {filename}|'")
416 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
420 # [ CLI ENTRY ] ###############################################################
422 if __name__ == "__main__":
423 args = parse_cli_args()
425 if args.mode == "setup":
426 if args.send_message:
427 raise NotImplementedError()
429 do_setup(args.extensions, debug_commands=args.debug_commands)
431 elif args.mode == "massage":
432 with open(args.cmdpath, "w") as cmd_f:
436 extensions=args.extensions,
437 debug_commands=args.debug_commands,
438 debug_walk=args.debug_walk,
442 # [ TESTS ] ###################################################################
450 return "CONSTANT STRING 1"
454 return "CONSTANT STRING 2"
456 # NOTE: tests using the capsys fixture must specify sys.stdout to the
457 # functions they call, else old stdout is used and not captured
459 def test_MuttCommands_cmd(self, const1, const2, capsys):
460 "Assert order of commands"
461 cmds = MuttCommands(out_f=sys.stdout)
465 captured = capsys.readouterr()
466 assert captured.out == "\n".join((const1, const2, ""))
468 def test_MuttCommands_push(self, const1, const2, capsys):
469 "Assert reverse order of pushes"
470 cmds = MuttCommands(out_f=sys.stdout)
474 captured = capsys.readouterr()
477 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
480 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
481 "Assert reverse order of pushes"
482 cmds = MuttCommands(out_f=sys.stdout)
483 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
485 cmds.cmd(lines[4 * i + 0])
486 cmds.cmd(lines[4 * i + 1])
487 cmds.push(lines[4 * i + 2])
488 cmds.push(lines[4 * i + 3])
491 captured = capsys.readouterr()
492 lines_out = captured.out.splitlines()
493 assert lines[0] in lines_out[0]
494 assert lines[1] in lines_out[1]
495 assert lines[7] in lines_out[2]
496 assert lines[6] in lines_out[3]
497 assert lines[3] in lines_out[4]
498 assert lines[2] in lines_out[5]
499 assert lines[4] in lines_out[6]
500 assert lines[5] in lines_out[7]
503 def basic_mime_tree(self):
517 Part("text", "html", "part.html", desc="HTML"),
522 "text", "png", "logo.png", cid="logo.png", desc="Logo"
528 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
529 mimetree = MIMETreeDFWalker()
533 def visitor_fn(item, stack, debugprint):
534 items.append((item, len(stack)))
536 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
537 assert len(items) == 5
538 assert items[0][0].subtype == "plain"
539 assert items[0][1] == 2
540 assert items[1][0].subtype == "html"
541 assert items[1][1] == 2
542 assert items[2][0].subtype == "alternative"
543 assert items[2][1] == 1
544 assert items[3][0].subtype == "png"
545 assert items[3][1] == 1
546 assert items[4][0].subtype == "relative"
547 assert items[4][1] == 0
549 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
550 mimetree = MIMETreeDFWalker()
553 def visitor_fn(item, stack, debugprint):
556 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
557 assert items[-1].subtype == "mixed"
559 def test_MIMETreeDFWalker_visitor_in_constructor(
560 self, basic_mime_tree
564 def visitor_fn(item, stack, debugprint):
567 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
568 mimetree.walk(basic_mime_tree)
569 assert len(items) == 5
571 def test_do_setup_no_extensions(self, const1, capsys):
572 "Assert basics about the setup command output"
573 do_setup(temppath=const1, out_f=sys.stdout)
574 captout = capsys.readouterr()
575 lines = captout.out.splitlines()
576 assert lines[2].endswith(f'{const1}"')
577 assert lines[4].endswith(const1)
578 assert "first-entry" in lines[-1]
579 assert "edit-file" in lines[-1]
581 def test_do_setup_extensions(self, const1, const2, capsys):
582 "Assert that extensions are passed to editor"
584 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
586 captout = capsys.readouterr()
587 lines = captout.out.splitlines()
588 # assert comma-separated list of extensions passed
589 assert lines[2].endswith(f'{const2},{const1}"')
590 assert lines[4].endswith(const1)
592 def test_do_massage_basic(self, const1, capsys):
593 def converter(maildraft, extensions):
594 return Part("text", "plain", "/dev/null", orig=True)
596 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
597 captured = capsys.readouterr()
601 set editor="$my_editor"
602 set edit_headers="$my_edit_headers"
604 unset my_edit_headers
605 source 'rm -f pytest_internal_file|'
606 unset my_mdwn_postprocess_cmd_file
612 def test_do_massage_fulltree(self, const1, basic_mime_tree, capsys):
613 def converter(maildraft, extensions):
614 return basic_mime_tree
616 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
617 captured = capsys.readouterr()
618 lines = captured.out.splitlines()[4:][::-1]
619 assert "Related" in lines.pop()
620 assert "group-related" in lines.pop()
621 assert "tag-entry" in lines.pop()
622 assert "Logo" in lines.pop()
623 assert "content-id" in lines.pop()
624 assert "toggle-unlink" in lines.pop()
625 assert "logo.png" in lines.pop()
626 assert "tag-entry" in lines.pop()
627 assert "Alternative" in lines.pop()
628 assert "group-alternatives" in lines.pop()
629 assert "tag-entry" in lines.pop()
630 assert "HTML" in lines.pop()
631 assert "toggle-unlink" in lines.pop()
632 assert "part.html" in lines.pop()
633 assert "tag-entry" in lines.pop()
634 assert "Plain" in lines.pop()