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)
146 "text", "plain", draftpath, "Plain-text version", orig=True
149 with open(draftpath, "r", encoding="utf-8") as textmarkdown:
150 text = textmarkdown.read()
152 mdwn = markdown.Markdown(extensions=extensions)
153 html = mdwn.convert(text)
155 htmlpath = draftpath.with_suffix(".html")
156 htmlpart = Part("text", "html", htmlpath, "HTML version")
159 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
166 "/usr/share/doc/neomutt/logo/neomutt-256.png",
176 [textpart, htmlpart],
177 "Group of alternative content",
181 "Group of related content",
185 class MIMETreeDFWalker:
186 def __init__(self, *, visitor_fn=None, debug=False):
187 self._visitor_fn = visitor_fn
190 def walk(self, root, *, visitor_fn=None):
192 Recursive function to implement a depth-dirst walk of the MIME-tree
196 if isinstance(root, list):
197 root = Multipart("mixed", children=root)
202 visitor_fn=visitor_fn or self._visitor_fn,
205 def _walk(self, node, *, stack, visitor_fn):
206 # Let's start by enumerating the parts at the current level. At the
207 # root level, stack will be the empty list, and we expect a multipart/*
208 # container at this level. Later, e.g. within a mutlipart/alternative
209 # container, the subtree will just be the alternative parts, while the
210 # top of the stack will be the multipart/alternative container, which
211 # we will process after the following loop.
213 lead = f"{'| '*len(stack)}|-"
214 if isinstance(node, Multipart):
216 f"{lead}{node} parents={[s.subtype for s in stack]}"
219 # Depth-first, so push the current container onto the stack,
222 self.debugprint("| " * (len(stack) + 1))
223 for child in node.children:
227 visitor_fn=visitor_fn,
229 self.debugprint("| " * len(stack))
230 assert stack.pop() == node
233 self.debugprint(f"{lead}{node}")
236 visitor_fn(node, stack, debugprint=self.debugprint)
238 def debugprint(self, s, **kwargs):
240 print(s, file=sys.stderr, **kwargs)
243 # [ RUN MODES ] ###############################################################
248 Stupid class to interface writing out Mutt commands. This is quite a hack
249 to deal with the fact that Mutt runs "push" commands in reverse order, so
250 all of a sudden, things become very complicated when mixing with "real"
253 Hence we keep two sets of commands, and one set of pushes. Commands are
254 added to the first until a push is added, after which commands are added to
255 the second set of commands.
257 On flush(), the first set is printed, followed by the pushes in reverse,
258 and then the second set is printed. All 3 sets are then cleared.
261 def __init__(self, out_f=sys.stdout, *, debug=False):
262 self._cmd1, self._push, self._cmd2 = [], [], []
274 s = s.replace('"', '"')
277 self._push.insert(0, s)
281 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
283 self._cmd1, self._push, self._cmd2 = [], [], []
285 def debugprint(self, s, **kwargs):
287 print(s, file=sys.stderr, **kwargs)
291 extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
293 extensions = extensions or []
294 temppath = temppath or pathlib.Path(
295 tempfile.mkstemp(prefix="muttmdwn-")[1]
297 cmds = MuttCommands(out_f, debug=debug_commands)
299 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
301 editor = f'{editor} --extensions {",".join(extensions)}'
303 cmds.cmd('set my_editor="$editor"')
304 cmds.cmd('set my_edit_headers="$edit_headers"')
305 cmds.cmd(f'set editor="{editor}"')
306 cmds.cmd("unset edit_headers")
307 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
308 cmds.push("<first-entry><edit-file>")
317 converter=convert_markdown_to_html,
318 debug_commands=False,
321 # Here's the big picture: we're being invoked as the editor on the email
322 # draft, and whatever commands we write to the file given as cmdpath will
323 # be run by the second source command in the macro definition.
325 # Let's start by cleaning up what the setup did (see above), i.e. we
326 # restore the $editor and $edit_headers variables, and also unset the
327 # variable used to identify the command file we're currently writing
329 cmds = MuttCommands(cmd_f, debug=debug_commands)
330 cmds.cmd('set editor="$my_editor"')
331 cmds.cmd('set edit_headers="$my_edit_headers"')
332 cmds.cmd("unset my_editor")
333 cmds.cmd("unset my_edit_headers")
335 # let's flush those commands, as there'll be a lot of pushes from now
336 # on, which need to be run in reverse order
339 extensions = extensions.split(",") if extensions else []
340 tree = converter(maildraft, extensions=extensions)
342 mimetree = MIMETreeDFWalker(debug=debug_walk)
344 def visitor_fn(item, stack, *, debugprint=None):
346 Visitor function called for every node (part) of the MIME tree,
347 depth-first, and responsible for telling NeoMutt how to assemble
350 if isinstance(item, Part):
351 # We've hit a leaf-node, i.e. an alternative or a related part
352 # with actual content.
354 # If the part is not an original part, i.e. doesn't already
355 # exist, we must first add it.
357 cmds.push(f"<attach-file>{item.path}<enter>")
358 cmds.push("<toggle-unlink><toggle-disposition>")
360 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
362 # If the item (including the original) comes with a
363 # description, then we might just as well update the NeoMutt
366 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
368 elif isinstance(item, Multipart):
369 # This node has children, but we already visited them (see
370 # above), and so they have been tagged in NeoMutt's compose
371 # window. Now it's just a matter of telling NeoMutt to do the
372 # appropriate grouping:
373 if item.subtype == "alternative":
374 cmds.push("<group-alternatives>")
375 elif item.subtype == "relative":
376 cmds.push("<group-related>")
377 elif item.subtype == "multilingual":
378 cmds.push("<group-multilingual>")
380 # Again, if there is a description, we might just as well:
382 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
385 # We should never get here
386 assert not "is valid part"
388 # Finally, if we're at non-root level, tag the new container,
389 # as it might itself be part of a container, to be processed
392 cmds.push("<tag-entry>")
397 # Let's walk the tree and visit every node with our fancy visitor
399 mimetree.walk(tree, visitor_fn=visitor_fn)
401 # Finally, cleanup. Since we're responsible for removing the temporary
402 # file, how's this for a little hack?
404 filename = cmd_f.name
405 except AttributeError:
406 filename = "pytest_internal_file"
407 cmds.cmd(f"source 'rm -f {filename}|'")
408 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
412 # [ CLI ENTRY ] ###############################################################
414 if __name__ == "__main__":
415 args = parse_cli_args()
417 if args.mode == "setup":
418 if args.send_message:
419 raise NotImplementedError()
421 do_setup(args.extensions, debug_commands=args.debug_commands)
423 elif args.mode == "massage":
424 with open(args.cmdpath, "w") as cmd_f:
428 extensions=args.extensions,
429 debug_commands=args.debug_commands,
430 debug_walk=args.debug_walk,
434 # [ TESTS ] ###################################################################
442 return "CONSTANT STRING 1"
446 return "CONSTANT STRING 2"
448 # NOTE: tests using the capsys fixture must specify sys.stdout to the
449 # functions they call, else old stdout is used and not captured
451 def test_MuttCommands_cmd(self, const1, const2, capsys):
452 "Assert order of commands"
453 cmds = MuttCommands(out_f=sys.stdout)
457 captured = capsys.readouterr()
458 assert captured.out == "\n".join((const1, const2, ""))
460 def test_MuttCommands_push(self, const1, const2, capsys):
461 "Assert reverse order of pushes"
462 cmds = MuttCommands(out_f=sys.stdout)
466 captured = capsys.readouterr()
469 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
472 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
473 "Assert reverse order of pushes"
474 cmds = MuttCommands(out_f=sys.stdout)
475 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
477 cmds.cmd(lines[4 * i + 0])
478 cmds.cmd(lines[4 * i + 1])
479 cmds.push(lines[4 * i + 2])
480 cmds.push(lines[4 * i + 3])
483 captured = capsys.readouterr()
484 lines_out = captured.out.splitlines()
485 assert lines[0] in lines_out[0]
486 assert lines[1] in lines_out[1]
487 assert lines[7] in lines_out[2]
488 assert lines[6] in lines_out[3]
489 assert lines[3] in lines_out[4]
490 assert lines[2] in lines_out[5]
491 assert lines[4] in lines_out[6]
492 assert lines[5] in lines_out[7]
495 def basic_mime_tree(self):
509 Part("text", "html", "part.html", desc="HTML"),
514 "text", "png", "logo.png", cid="logo.png", desc="Logo"
520 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
521 mimetree = MIMETreeDFWalker()
525 def visitor_fn(item, stack, debugprint):
526 items.append((item, len(stack)))
528 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
529 assert len(items) == 5
530 assert items[0][0].subtype == "plain"
531 assert items[0][1] == 2
532 assert items[1][0].subtype == "html"
533 assert items[1][1] == 2
534 assert items[2][0].subtype == "alternative"
535 assert items[2][1] == 1
536 assert items[3][0].subtype == "png"
537 assert items[3][1] == 1
538 assert items[4][0].subtype == "relative"
539 assert items[4][1] == 0
541 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
542 mimetree = MIMETreeDFWalker()
545 def visitor_fn(item, stack, debugprint):
548 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
549 assert items[-1].subtype == "mixed"
551 def test_MIMETreeDFWalker_visitor_in_constructor(
552 self, basic_mime_tree
556 def visitor_fn(item, stack, debugprint):
559 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
560 mimetree.walk(basic_mime_tree)
561 assert len(items) == 5
563 def test_do_setup_no_extensions(self, const1, capsys):
564 "Assert basics about the setup command output"
565 do_setup(temppath=const1, out_f=sys.stdout)
566 captout = capsys.readouterr()
567 lines = captout.out.splitlines()
568 assert lines[2].endswith(f'{const1}"')
569 assert lines[4].endswith(const1)
570 assert "first-entry" in lines[-1]
571 assert "edit-file" in lines[-1]
573 def test_do_setup_extensions(self, const1, const2, capsys):
574 "Assert that extensions are passed to editor"
576 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
578 captout = capsys.readouterr()
579 lines = captout.out.splitlines()
580 # assert comma-separated list of extensions passed
581 assert lines[2].endswith(f'{const2},{const1}"')
582 assert lines[4].endswith(const1)
584 def test_do_massage_basic(self, const1, capsys):
585 def converter(maildraft, extensions):
586 return Part("text", "plain", "/dev/null", orig=True)
588 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
589 captured = capsys.readouterr()
593 set editor="$my_editor"
594 set edit_headers="$my_edit_headers"
596 unset my_edit_headers
597 source 'rm -f pytest_internal_file|'
598 unset my_mdwn_postprocess_cmd_file
604 def test_do_massage_fulltree(self, const1, basic_mime_tree, capsys):
605 def converter(maildraft, extensions):
606 return basic_mime_tree
608 do_massage(maildraft=const1, cmd_f=sys.stdout, converter=converter)
609 captured = capsys.readouterr()
610 lines = captured.out.splitlines()[4:][::-1]
611 assert "Related" in lines.pop()
612 assert "group-related" in lines.pop()
613 assert "tag-entry" in lines.pop()
614 assert "Logo" in lines.pop()
615 assert "content-id" in lines.pop()
616 assert "toggle-unlink" in lines.pop()
617 assert "logo.png" in lines.pop()
618 assert "tag-entry" in lines.pop()
619 assert "Alternative" in lines.pop()
620 assert "group-alternatives" in lines.pop()
621 assert "tag-entry" in lines.pop()
622 assert "HTML" in lines.pop()
623 assert "toggle-unlink" in lines.pop()
624 assert "part.html" in lines.pop()
625 assert "tag-entry" in lines.pop()
626 assert "Plain" in lines.pop()