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 elif isinstance(item, Multipart):
375 # This node has children, but we already visited them (see
376 # above), and so they have been tagged in NeoMutt's compose
377 # window. Now it's just a matter of telling NeoMutt to do the
378 # appropriate grouping:
379 if item.subtype == "alternative":
380 cmds.push("<group-alternatives>")
381 elif item.subtype == "relative":
382 cmds.push("<group-related>")
383 elif item.subtype == "multilingual":
384 cmds.push("<group-multilingual>")
387 # We should never get here
388 assert not "is valid part"
390 # If the item has a description, we might just as well add it
392 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
394 # Finally, if we're at non-root level, tag the new container,
395 # as it might itself be part of a container, to be processed
398 cmds.push("<tag-entry>")
403 # Let's walk the tree and visit every node with our fancy visitor
405 mimetree.walk(tree, visitor_fn=visitor_fn)
407 # Finally, cleanup. Since we're responsible for removing the temporary
408 # file, how's this for a little hack?
410 filename = cmd_f.name
411 except AttributeError:
412 filename = "pytest_internal_file"
413 cmds.cmd(f"source 'rm -f {filename}|'")
414 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
418 # [ CLI ENTRY ] ###############################################################
420 if __name__ == "__main__":
421 args = parse_cli_args()
423 if args.mode == "setup":
424 if args.send_message:
425 raise NotImplementedError()
427 do_setup(args.extensions, debug_commands=args.debug_commands)
429 elif args.mode == "massage":
430 with open(args.cmdpath, "w") as cmd_f:
434 extensions=args.extensions,
435 debug_commands=args.debug_commands,
436 debug_walk=args.debug_walk,
440 # [ TESTS ] ###################################################################
448 return "CONSTANT STRING 1"
452 return "CONSTANT STRING 2"
454 # NOTE: tests using the capsys fixture must specify sys.stdout to the
455 # functions they call, else old stdout is used and not captured
457 def test_MuttCommands_cmd(self, const1, const2, capsys):
458 "Assert order of commands"
459 cmds = MuttCommands(out_f=sys.stdout)
463 captured = capsys.readouterr()
464 assert captured.out == "\n".join((const1, const2, ""))
466 def test_MuttCommands_push(self, const1, const2, capsys):
467 "Assert reverse order of pushes"
468 cmds = MuttCommands(out_f=sys.stdout)
472 captured = capsys.readouterr()
475 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
478 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
479 "Assert reverse order of pushes"
480 cmds = MuttCommands(out_f=sys.stdout)
481 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
483 cmds.cmd(lines[4 * i + 0])
484 cmds.cmd(lines[4 * i + 1])
485 cmds.push(lines[4 * i + 2])
486 cmds.push(lines[4 * i + 3])
489 captured = capsys.readouterr()
490 lines_out = captured.out.splitlines()
491 assert lines[0] in lines_out[0]
492 assert lines[1] in lines_out[1]
493 assert lines[7] in lines_out[2]
494 assert lines[6] in lines_out[3]
495 assert lines[3] in lines_out[4]
496 assert lines[2] in lines_out[5]
497 assert lines[4] in lines_out[6]
498 assert lines[5] in lines_out[7]
501 def basic_mime_tree(self):
515 Part("text", "html", "part.html", desc="HTML"),
520 "text", "png", "logo.png", cid="logo.png", desc="Logo"
526 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
527 mimetree = MIMETreeDFWalker()
531 def visitor_fn(item, stack, debugprint):
532 items.append((item, len(stack)))
534 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
535 assert len(items) == 5
536 assert items[0][0].subtype == "plain"
537 assert items[0][1] == 2
538 assert items[1][0].subtype == "html"
539 assert items[1][1] == 2
540 assert items[2][0].subtype == "alternative"
541 assert items[2][1] == 1
542 assert items[3][0].subtype == "png"
543 assert items[3][1] == 1
544 assert items[4][0].subtype == "relative"
545 assert items[4][1] == 0
547 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
548 mimetree = MIMETreeDFWalker()
551 def visitor_fn(item, stack, debugprint):
554 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
555 assert items[-1].subtype == "mixed"
557 def test_MIMETreeDFWalker_visitor_in_constructor(
558 self, basic_mime_tree
562 def visitor_fn(item, stack, debugprint):
565 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
566 mimetree.walk(basic_mime_tree)
567 assert len(items) == 5
569 def test_do_setup_no_extensions(self, const1, capsys):
570 "Assert basics about the setup command output"
571 do_setup(temppath=const1, out_f=sys.stdout)
572 captout = capsys.readouterr()
573 lines = captout.out.splitlines()
574 assert lines[2].endswith(f'{const1}"')
575 assert lines[4].endswith(const1)
576 assert "first-entry" in lines[-1]
577 assert "edit-file" in lines[-1]
579 def test_do_setup_extensions(self, const1, const2, capsys):
580 "Assert that extensions are passed to editor"
582 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
584 captout = capsys.readouterr()
585 lines = captout.out.splitlines()
586 # assert comma-separated list of extensions passed
587 assert lines[2].endswith(f'{const2},{const1}"')
588 assert lines[4].endswith(const1)
590 def test_do_massage_basic(self, const1, capsys):
591 def converter(maildraft, extensions):
592 return Part("text", "plain", "/dev/null", orig=True)
594 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
595 captured = capsys.readouterr()
599 set editor="$my_editor"
600 set edit_headers="$my_edit_headers"
602 unset my_edit_headers
603 source 'rm -f pytest_internal_file|'
604 unset my_mdwn_postprocess_cmd_file
610 def test_do_massage_fulltree(self, const1, basic_mime_tree, capsys):
611 def converter(maildraft, extensions):
612 return basic_mime_tree
614 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
615 captured = capsys.readouterr()
616 lines = captured.out.splitlines()[4:][::-1]
617 assert "Related" in lines.pop()
618 assert "group-related" in lines.pop()
619 assert "tag-entry" in lines.pop()
620 assert "Logo" in lines.pop()
621 assert "content-id" in lines.pop()
622 assert "toggle-unlink" in lines.pop()
623 assert "logo.png" in lines.pop()
624 assert "tag-entry" in lines.pop()
625 assert "Alternative" in lines.pop()
626 assert "group-alternatives" in lines.pop()
627 assert "tag-entry" in lines.pop()
628 assert "HTML" in lines.pop()
629 assert "toggle-unlink" in lines.pop()
630 assert "part.html" in lines.pop()
631 assert "tag-entry" in lines.pop()
632 assert "Plain" in lines.pop()