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 cmds.cmd('set my_editor="$editor"')
309 cmds.cmd('set my_edit_headers="$edit_headers"')
310 cmds.cmd(f'set editor="{editor}"')
311 cmds.cmd("unset edit_headers")
312 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
313 cmds.push("<first-entry><edit-file>")
322 converter=convert_markdown_to_html,
323 debug_commands=False,
326 # Here's the big picture: we're being invoked as the editor on the email
327 # draft, and whatever commands we write to the file given as cmdpath will
328 # be run by the second source command in the macro definition.
330 # Let's start by cleaning up what the setup did (see above), i.e. we
331 # restore the $editor and $edit_headers variables, and also unset the
332 # variable used to identify the command file we're currently writing
334 cmds = MuttCommands(cmd_f, debug=debug_commands)
335 cmds.cmd('set editor="$my_editor"')
336 cmds.cmd('set edit_headers="$my_edit_headers"')
337 cmds.cmd("unset my_editor")
338 cmds.cmd("unset my_edit_headers")
340 # let's flush those commands, as there'll be a lot of pushes from now
341 # on, which need to be run in reverse order
344 extensions = extensions.split(",") if extensions else []
345 with open(maildraft, "r") as draft_f:
347 draft_f.read(), pathlib.Path(maildraft), 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.cmdpath, "w") as cmd_f:
440 extensions=args.extensions,
441 debug_commands=args.debug_commands,
442 debug_walk=args.debug_walk,
446 # [ TESTS ] ###################################################################
454 return "CONSTANT STRING 1"
458 return "CONSTANT STRING 2"
460 # NOTE: tests using the capsys fixture must specify sys.stdout to the
461 # functions they call, else old stdout is used and not captured
463 def test_MuttCommands_cmd(self, const1, const2, capsys):
464 "Assert order of commands"
465 cmds = MuttCommands(out_f=sys.stdout)
469 captured = capsys.readouterr()
470 assert captured.out == "\n".join((const1, const2, ""))
472 def test_MuttCommands_push(self, const1, const2, capsys):
473 "Assert reverse order of pushes"
474 cmds = MuttCommands(out_f=sys.stdout)
478 captured = capsys.readouterr()
481 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
484 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
485 "Assert reverse order of pushes"
486 cmds = MuttCommands(out_f=sys.stdout)
487 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
489 cmds.cmd(lines[4 * i + 0])
490 cmds.cmd(lines[4 * i + 1])
491 cmds.push(lines[4 * i + 2])
492 cmds.push(lines[4 * i + 3])
495 captured = capsys.readouterr()
496 lines_out = captured.out.splitlines()
497 assert lines[0] in lines_out[0]
498 assert lines[1] in lines_out[1]
499 assert lines[7] in lines_out[2]
500 assert lines[6] in lines_out[3]
501 assert lines[3] in lines_out[4]
502 assert lines[2] in lines_out[5]
503 assert lines[4] in lines_out[6]
504 assert lines[5] in lines_out[7]
507 def basic_mime_tree(self):
521 Part("text", "html", "part.html", desc="HTML"),
526 "text", "png", "logo.png", cid="logo.png", desc="Logo"
532 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
533 mimetree = MIMETreeDFWalker()
537 def visitor_fn(item, stack, debugprint):
538 items.append((item, len(stack)))
540 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
541 assert len(items) == 5
542 assert items[0][0].subtype == "plain"
543 assert items[0][1] == 2
544 assert items[1][0].subtype == "html"
545 assert items[1][1] == 2
546 assert items[2][0].subtype == "alternative"
547 assert items[2][1] == 1
548 assert items[3][0].subtype == "png"
549 assert items[3][1] == 1
550 assert items[4][0].subtype == "relative"
551 assert items[4][1] == 0
553 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
554 mimetree = MIMETreeDFWalker()
557 def visitor_fn(item, stack, debugprint):
560 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
561 assert items[-1].subtype == "mixed"
563 def test_MIMETreeDFWalker_visitor_in_constructor(
564 self, basic_mime_tree
568 def visitor_fn(item, stack, debugprint):
571 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
572 mimetree.walk(basic_mime_tree)
573 assert len(items) == 5
575 def test_do_setup_no_extensions(self, const1, capsys):
576 "Assert basics about the setup command output"
577 do_setup(temppath=const1, out_f=sys.stdout)
578 captout = capsys.readouterr()
579 lines = captout.out.splitlines()
580 assert lines[2].endswith(f'{const1}"')
581 assert lines[4].endswith(const1)
582 assert "first-entry" in lines[-1]
583 assert "edit-file" in lines[-1]
585 def test_do_setup_extensions(self, const1, const2, capsys):
586 "Assert that extensions are passed to editor"
588 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
590 captout = capsys.readouterr()
591 lines = captout.out.splitlines()
592 # assert comma-separated list of extensions passed
593 assert lines[2].endswith(f'{const2},{const1}"')
594 assert lines[4].endswith(const1)
596 def test_do_massage_basic(self, const1, capsys):
597 def converter(maildraft, extensions):
598 return Part("text", "plain", "/dev/null", orig=True)
600 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
601 captured = capsys.readouterr()
605 set editor="$my_editor"
606 set edit_headers="$my_edit_headers"
608 unset my_edit_headers
609 source 'rm -f pytest_internal_file|'
610 unset my_mdwn_postprocess_cmd_file
616 def test_do_massage_fulltree(self, const1, basic_mime_tree, capsys):
617 def converter(maildraft, extensions):
618 return basic_mime_tree
620 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
621 captured = capsys.readouterr()
622 lines = captured.out.splitlines()[4:][::-1]
623 assert "Related" in lines.pop()
624 assert "group-related" in lines.pop()
625 assert "tag-entry" in lines.pop()
626 assert "Logo" in lines.pop()
627 assert "content-id" in lines.pop()
628 assert "toggle-unlink" in lines.pop()
629 assert "logo.png" in lines.pop()
630 assert "tag-entry" in lines.pop()
631 assert "Alternative" in lines.pop()
632 assert "group-alternatives" in lines.pop()
633 assert "tag-entry" in lines.pop()
634 assert "HTML" in lines.pop()
635 assert "toggle-unlink" in lines.pop()
636 assert "part.html" in lines.pop()
637 assert "tag-entry" in lines.pop()
638 assert "Plain" in lines.pop()