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.
41 from collections import namedtuple, OrderedDict
42 from markdown.extensions import Extension
43 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
44 from email.utils import make_msgid
45 from urllib import request
48 def parse_cli_args(*args, **kwargs):
49 parser = argparse.ArgumentParser(
51 "NeoMutt helper to turn text/markdown email parts "
52 "into full-fledged MIME trees"
56 "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
57 "Released under the MIT licence"
60 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
61 parser_setup = subp.add_parser("setup", help="Setup phase")
62 parser_massage = subp.add_parser("massage", help="Massaging phase")
64 parser_setup.add_argument(
67 help="Turn on debug logging of commands generated to stderr",
70 parser_setup.add_argument(
78 help="Markdown extension to add to the list of extensions use",
81 parser_setup.add_argument(
84 help="Generate command(s) to send the message after processing",
87 parser_massage.add_argument(
90 help="Turn on debug logging of commands generated to stderr",
93 parser_massage.add_argument(
96 help="Turn on debugging to stderr of the MIME tree walk",
99 parser_massage.add_argument(
101 metavar="EXTENSIONS",
104 help="Markdown extension to use (comma-separated list)",
107 parser_massage.add_argument(
108 "--write-commands-to",
111 help="Temporary file path to write commands to",
114 parser_massage.add_argument(
117 help="If provided, the script is invoked as editor on the mail draft",
120 return parser.parse_args(*args, **kwargs)
123 # [ MARKDOWN WRAPPING ] #######################################################
126 InlineImageInfo = namedtuple(
127 "InlineImageInfo", ["cid", "desc"], defaults=[None]
131 class InlineImageExtension(Extension):
132 class RelatedImageInlineProcessor(ImageInlineProcessor):
133 def __init__(self, re, md, ext):
134 super().__init__(re, md)
137 def handleMatch(self, m, data):
138 el, start, end = super().handleMatch(m, data)
139 if "src" in el.attrib:
140 src = el.attrib["src"]
141 if "://" not in src or src.startswith("file://"):
142 # We only inline local content
143 cid = self._ext.get_cid_for_image(el.attrib)
144 el.attrib["src"] = f"cid:{cid}"
145 return el, start, end
149 self._images = OrderedDict()
151 def extendMarkdown(self, md):
152 md.registerExtension(self)
153 inline_image_proc = self.RelatedImageInlineProcessor(
154 IMAGE_LINK_RE, md, self
156 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
158 def get_cid_for_image(self, attrib):
159 msgid = make_msgid()[1:-1]
161 if path.startswith("/"):
162 path = f"file://{path}"
163 self._images[path] = InlineImageInfo(
164 msgid, attrib.get("title", attrib.get("alt"))
168 def get_images(self):
172 def markdown_with_inline_image_support(text, *, extensions=None):
173 inline_image_handler = InlineImageExtension()
174 extensions = extensions or []
175 extensions.append(inline_image_handler)
176 mdwn = markdown.Markdown(extensions=extensions)
177 htmltext = mdwn.convert(text)
179 images = inline_image_handler.get_images()
181 def replace_image_with_cid(matchobj):
182 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
184 return f"(cid:{images[m].cid}"
185 return matchobj.group(0)
187 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
188 return text, htmltext, images
191 # [ PARTS GENERATION ] ########################################################
197 ["type", "subtype", "path", "desc", "cid", "orig"],
198 defaults=[None, None, False],
202 ret = f"<{self.type}/{self.subtype}>"
204 ret = f"{ret} cid:{self.cid}"
206 ret = f"{ret} ORIGINAL"
211 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
214 return f"<multipart/{self.subtype}> children={len(self.children)}"
217 def filewriter_fn(path, content, mode="w", **kwargs):
218 with open(path, mode, **kwargs) as out_f:
222 def collect_inline_images(
223 images, *, tempdir=None, filewriter_fn=filewriter_fn
226 for path, info in images.items():
227 data = request.urlopen(path)
229 mimetype = data.headers["Content-Type"]
230 ext = mimetypes.guess_extension(mimetype)
231 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)[
234 path = pathlib.Path(tempfilename)
236 filewriter_fn(path, data.read(), "w+b")
240 *mimetype.split("/"), path, cid=info.cid, desc=info.desc
247 def convert_markdown_to_html(
248 origtext, draftpath, *, filewriter_fn=filewriter_fn, extensions=None
250 origtext, htmltext, images = markdown_with_inline_image_support(
251 origtext, extensions=extensions
254 filewriter_fn(draftpath, origtext, encoding="utf-8")
256 "text", "plain", draftpath, "Plain-text version", orig=True
259 htmlpath = draftpath.with_suffix(".html")
261 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
263 htmlpart = Part("text", "html", htmlpath, "HTML version")
266 "alternative", [textpart, htmlpart], "Group of alternative content"
269 imgparts = collect_inline_images(images, filewriter_fn=filewriter_fn)
272 "relative", [altpart] + imgparts, "Group of related content"
278 class MIMETreeDFWalker:
279 def __init__(self, *, visitor_fn=None, debug=False):
280 self._visitor_fn = visitor_fn
283 def walk(self, root, *, visitor_fn=None):
285 Recursive function to implement a depth-dirst walk of the MIME-tree
289 if isinstance(root, list):
290 root = Multipart("mixed", children=root)
295 visitor_fn=visitor_fn or self._visitor_fn,
298 def _walk(self, node, *, stack, visitor_fn):
299 # Let's start by enumerating the parts at the current level. At the
300 # root level, stack will be the empty list, and we expect a multipart/*
301 # container at this level. Later, e.g. within a mutlipart/alternative
302 # container, the subtree will just be the alternative parts, while the
303 # top of the stack will be the multipart/alternative container, which
304 # we will process after the following loop.
306 lead = f"{'| '*len(stack)}|-"
307 if isinstance(node, Multipart):
309 f"{lead}{node} parents={[s.subtype for s in stack]}"
312 # Depth-first, so push the current container onto the stack,
315 self.debugprint("| " * (len(stack) + 1))
316 for child in node.children:
320 visitor_fn=visitor_fn,
322 self.debugprint("| " * len(stack))
323 assert stack.pop() == node
326 self.debugprint(f"{lead}{node}")
329 visitor_fn(node, stack, debugprint=self.debugprint)
331 def debugprint(self, s, **kwargs):
333 print(s, file=sys.stderr, **kwargs)
336 # [ RUN MODES ] ###############################################################
341 Stupid class to interface writing out Mutt commands. This is quite a hack
342 to deal with the fact that Mutt runs "push" commands in reverse order, so
343 all of a sudden, things become very complicated when mixing with "real"
346 Hence we keep two sets of commands, and one set of pushes. Commands are
347 added to the first until a push is added, after which commands are added to
348 the second set of commands.
350 On flush(), the first set is printed, followed by the pushes in reverse,
351 and then the second set is printed. All 3 sets are then cleared.
354 def __init__(self, out_f=sys.stdout, *, debug=False):
355 self._cmd1, self._push, self._cmd2 = [], [], []
367 s = s.replace('"', '"')
370 self._push.insert(0, s)
374 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
376 self._cmd1, self._push, self._cmd2 = [], [], []
378 def debugprint(self, s, **kwargs):
380 print(s, file=sys.stderr, **kwargs)
384 extensions=None, *, out_f=sys.stdout, temppath=None, debug_commands=False
386 extensions = extensions or []
387 temppath = temppath or pathlib.Path(
388 tempfile.mkstemp(prefix="muttmdwn-")[1]
390 cmds = MuttCommands(out_f, debug=debug_commands)
392 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
394 editor = f'{editor} --extensions {",".join(extensions)}'
396 editor = f'{editor} --debug-commands'
398 cmds.cmd('set my_editor="$editor"')
399 cmds.cmd('set my_edit_headers="$edit_headers"')
400 cmds.cmd(f'set editor="{editor}"')
401 cmds.cmd("unset edit_headers")
402 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
403 cmds.push("<first-entry><edit-file>")
413 converter=convert_markdown_to_html,
414 debug_commands=False,
417 # Here's the big picture: we're being invoked as the editor on the email
418 # draft, and whatever commands we write to the file given as cmdpath will
419 # be run by the second source command in the macro definition.
421 # Let's start by cleaning up what the setup did (see above), i.e. we
422 # restore the $editor and $edit_headers variables, and also unset the
423 # variable used to identify the command file we're currently writing
425 cmds = MuttCommands(cmd_f, debug=debug_commands)
426 cmds.cmd('set editor="$my_editor"')
427 cmds.cmd('set edit_headers="$my_edit_headers"')
428 cmds.cmd("unset my_editor")
429 cmds.cmd("unset my_edit_headers")
431 # let's flush those commands, as there'll be a lot of pushes from now
432 # on, which need to be run in reverse order
435 extensions = extensions.split(",") if extensions else []
436 tree = converter(draft_f.read(), draftpath, extensions=extensions)
438 mimetree = MIMETreeDFWalker(debug=debug_walk)
440 def visitor_fn(item, stack, *, debugprint=None):
442 Visitor function called for every node (part) of the MIME tree,
443 depth-first, and responsible for telling NeoMutt how to assemble
446 if isinstance(item, Part):
447 # We've hit a leaf-node, i.e. an alternative or a related part
448 # with actual content.
452 # The original source already exists in the NeoMutt tree, but
453 # the underlying file may have been modified, so we need to
454 # update the encoding, but that's it:
455 cmds.push("<update-encoding>")
457 # … whereas all other parts need to be added, and they're all
458 # considered to be temporary and inline:
459 cmds.push(f"<attach-file>{item.path}<enter>")
460 cmds.push("<toggle-unlink><toggle-disposition>")
462 # If the item (including the original) comes with additional
463 # information, then we might just as well update the NeoMutt
466 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
468 elif isinstance(item, Multipart):
469 # This node has children, but we already visited them (see
470 # above), and so they have been tagged in NeoMutt's compose
471 # window. Now it's just a matter of telling NeoMutt to do the
472 # appropriate grouping:
473 if item.subtype == "alternative":
474 cmds.push("<group-alternatives>")
475 elif item.subtype == "relative":
476 cmds.push("<group-related>")
477 elif item.subtype == "multilingual":
478 cmds.push("<group-multilingual>")
481 # We should never get here
482 assert not "is valid part"
484 # If the item has a description, we might just as well add it
486 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
488 # Finally, if we're at non-root level, tag the new container,
489 # as it might itself be part of a container, to be processed
492 cmds.push("<tag-entry>")
497 # Let's walk the tree and visit every node with our fancy visitor
499 mimetree.walk(tree, visitor_fn=visitor_fn)
501 # Finally, cleanup. Since we're responsible for removing the temporary
502 # file, how's this for a little hack?
504 filename = cmd_f.name
505 except AttributeError:
506 filename = "pytest_internal_file"
507 cmds.cmd(f"source 'rm -f {filename}|'")
508 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
512 # [ CLI ENTRY ] ###############################################################
514 if __name__ == "__main__":
515 args = parse_cli_args()
517 if args.mode == "setup":
518 if args.send_message:
519 raise NotImplementedError()
521 do_setup(args.extensions, debug_commands=args.debug_commands)
523 elif args.mode == "massage":
524 with open(args.MAILDRAFT, "r") as draft_f, open(
529 pathlib.Path(args.MAILDRAFT),
531 extensions=args.extensions,
532 debug_commands=args.debug_commands,
533 debug_walk=args.debug_walk,
537 # [ TESTS ] ###################################################################
541 from io import StringIO
546 return "CONSTANT STRING 1"
550 return "CONSTANT STRING 2"
552 # NOTE: tests using the capsys fixture must specify sys.stdout to the
553 # functions they call, else old stdout is used and not captured
555 def test_MuttCommands_cmd(self, const1, const2, capsys):
556 "Assert order of commands"
557 cmds = MuttCommands(out_f=sys.stdout)
561 captured = capsys.readouterr()
562 assert captured.out == "\n".join((const1, const2, ""))
564 def test_MuttCommands_push(self, const1, const2, capsys):
565 "Assert reverse order of pushes"
566 cmds = MuttCommands(out_f=sys.stdout)
570 captured = capsys.readouterr()
573 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
576 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
577 "Assert reverse order of pushes"
578 cmds = MuttCommands(out_f=sys.stdout)
579 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
581 cmds.cmd(lines[4 * i + 0])
582 cmds.cmd(lines[4 * i + 1])
583 cmds.push(lines[4 * i + 2])
584 cmds.push(lines[4 * i + 3])
587 captured = capsys.readouterr()
588 lines_out = captured.out.splitlines()
589 assert lines[0] in lines_out[0]
590 assert lines[1] in lines_out[1]
591 assert lines[7] in lines_out[2]
592 assert lines[6] in lines_out[3]
593 assert lines[3] in lines_out[4]
594 assert lines[2] in lines_out[5]
595 assert lines[4] in lines_out[6]
596 assert lines[5] in lines_out[7]
599 def basic_mime_tree(self):
613 Part("text", "html", "part.html", desc="HTML"),
618 "text", "png", "logo.png", cid="logo.png", desc="Logo"
624 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
625 mimetree = MIMETreeDFWalker()
629 def visitor_fn(item, stack, debugprint):
630 items.append((item, len(stack)))
632 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
633 assert len(items) == 5
634 assert items[0][0].subtype == "plain"
635 assert items[0][1] == 2
636 assert items[1][0].subtype == "html"
637 assert items[1][1] == 2
638 assert items[2][0].subtype == "alternative"
639 assert items[2][1] == 1
640 assert items[3][0].subtype == "png"
641 assert items[3][1] == 1
642 assert items[4][0].subtype == "relative"
643 assert items[4][1] == 0
645 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
646 mimetree = MIMETreeDFWalker()
649 def visitor_fn(item, stack, debugprint):
652 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
653 assert items[-1].subtype == "mixed"
655 def test_MIMETreeDFWalker_visitor_in_constructor(
656 self, basic_mime_tree
660 def visitor_fn(item, stack, debugprint):
663 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
664 mimetree.walk(basic_mime_tree)
665 assert len(items) == 5
667 def test_do_setup_no_extensions(self, const1, capsys):
668 "Assert basics about the setup command output"
669 do_setup(temppath=const1, out_f=sys.stdout)
670 captout = capsys.readouterr()
671 lines = captout.out.splitlines()
672 assert lines[2].endswith(f'{const1}"')
673 assert lines[4].endswith(const1)
674 assert "first-entry" in lines[-1]
675 assert "edit-file" in lines[-1]
677 def test_do_setup_extensions(self, const1, const2, capsys):
678 "Assert that extensions are passed to editor"
680 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
682 captout = capsys.readouterr()
683 lines = captout.out.splitlines()
684 # assert comma-separated list of extensions passed
685 assert lines[2].endswith(f'{const2},{const1}"')
686 assert lines[4].endswith(const1)
689 def string_io(self, const1, text=None):
690 return StringIO(text or const1)
692 def test_do_massage_basic(self, const1, string_io, capsys):
693 def converter(drafttext, draftpath, extensions):
694 return Part("text", "plain", draftpath, orig=True)
703 captured = capsys.readouterr()
704 lines = captured.out.splitlines()
705 assert '="$my_editor"' in lines.pop(0)
706 assert '="$my_edit_headers"' in lines.pop(0)
707 assert "unset my_editor" == lines.pop(0)
708 assert "unset my_edit_headers" == lines.pop(0)
709 assert "update-encoding" in lines.pop(0)
710 assert "source 'rm -f " in lines.pop(0)
711 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
713 def test_do_massage_fulltree(
714 self, string_io, const1, basic_mime_tree, capsys
716 def converter(drafttext, draftpath, extensions):
717 return basic_mime_tree
726 captured = capsys.readouterr()
727 lines = captured.out.splitlines()[4:]
728 assert "Related" in lines.pop(0)
729 assert "group-related" in lines.pop(0)
730 assert "tag-entry" in lines.pop(0)
731 assert "Logo" in lines.pop(0)
732 assert "content-id" in lines.pop(0)
733 assert "toggle-unlink" in lines.pop(0)
734 assert "logo.png" in lines.pop(0)
735 assert "tag-entry" in lines.pop(0)
736 assert "Alternative" in lines.pop(0)
737 assert "group-alternatives" in lines.pop(0)
738 assert "tag-entry" in lines.pop(0)
739 assert "HTML" in lines.pop(0)
740 assert "toggle-unlink" in lines.pop(0)
741 assert "part.html" in lines.pop(0)
742 assert "tag-entry" in lines.pop(0)
743 assert "Plain" in lines.pop(0)
744 assert "update-encoding" in lines.pop(0)
745 assert len(lines) == 2
748 def fake_filewriter(self):
753 def __call__(self, path, content, mode="w", **kwargs):
754 self._writes.append((path, content))
756 def pop(self, index=-1):
757 return self._writes.pop(index)
762 def markdown_non_converter(self, const1, const2):
763 return lambda s, text: f"{const1}{text}{const2}"
765 def test_converter_tree_basic(
766 self, const1, const2, fake_filewriter, markdown_non_converter
768 path = pathlib.Path(const2)
769 tree = convert_markdown_to_html(
770 const1, path, filewriter_fn=fake_filewriter
773 assert tree.subtype == "alternative"
774 assert len(tree.children) == 2
775 assert tree.children[0].subtype == "plain"
776 assert tree.children[0].path == path
777 assert tree.children[0].orig
778 assert tree.children[1].subtype == "html"
779 assert tree.children[1].path == path.with_suffix(".html")
781 def test_converter_writes(
787 markdown_non_converter,
789 path = pathlib.Path(const2)
791 with monkeypatch.context() as m:
792 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
793 convert_markdown_to_html(
794 const1, path, filewriter_fn=fake_filewriter
797 assert (path, const1) == fake_filewriter.pop(0)
799 path.with_suffix(".html"),
800 markdown_non_converter(None, const1),
801 ) == fake_filewriter.pop(0)
803 def test_markdown_inline_image_processor(self):
804 imgpath1 = "file:/path/to/image.png"
805 imgpath2 = "file:///path/to/image.png?url=params"
806 imgpath3 = "/path/to/image.png"
807 text = f"""![inline local image]({imgpath1})
809 with newline]({imgpath2})
810 ![image local path]({imgpath3})"""
811 text, html, images = markdown_with_inline_image_support(text)
813 # local paths have been normalised to URLs:
814 imgpath3 = f"file://{imgpath3}"
816 assert 'src="cid:' in html
817 assert "](cid:" in text
818 assert len(images) == 3
819 assert imgpath1 in images
820 assert imgpath2 in images
821 assert imgpath3 in images
822 assert images[imgpath1].cid != images[imgpath2].cid
823 assert images[imgpath1].cid != images[imgpath3].cid
824 assert images[imgpath2].cid != images[imgpath3].cid
826 def test_markdown_inline_image_processor_title_to_desc(self, const1):
827 imgpath = "file:///path/to/image.png"
828 text = f'![inline local image]({imgpath} "{const1}")'
829 text, html, images = markdown_with_inline_image_support(text)
830 assert images[imgpath].desc == const1
832 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
833 imgpath = "file:///path/to/image.png"
834 text = f"![{const1}]({imgpath})"
835 text, html, images = markdown_with_inline_image_support(text)
836 assert images[imgpath].desc == const1
838 def test_markdown_inline_image_processor_title_over_alt_desc(
841 imgpath = "file:///path/to/image.png"
842 text = f'![{const1}]({imgpath} "{const2}")'
843 text, html, images = markdown_with_inline_image_support(text)
844 assert images[imgpath].desc == const2
846 def test_markdown_inline_image_not_external(self):
847 imgpath = "https://path/to/image.png"
848 text = f"![inline image]({imgpath})"
849 text, html, images = markdown_with_inline_image_support(text)
851 assert 'src="cid:' not in html
852 assert "](cid:" not in text
853 assert len(images) == 0
855 def test_markdown_inline_image_local_file(self):
856 imgpath = "/path/to/image.png"
857 text = f"![inline image]({imgpath})"
858 text, html, images = markdown_with_inline_image_support(text)
860 for k, v in images.items():
861 assert k == f"file://{imgpath}"
864 def test_markdown_inline_image_processor_base64(self):
866 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
867 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
869 text = f"![1px white inlined]({img})"
870 text, html, images = markdown_with_inline_image_support(text)
872 assert 'src="cid:' in html
873 assert "](cid:" in text
874 assert len(images) == 1
877 def test_converter_tree_inline_image_base64(
878 self, const1, fake_filewriter
881 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
882 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
884 text = f"![inline base64 image]({img})"
885 path = pathlib.Path(const1)
886 tree = convert_markdown_to_html(
887 text, path, filewriter_fn=fake_filewriter
890 assert tree.subtype == "relative"
891 assert tree.children[1].subtype == "png"
892 written = fake_filewriter.pop()
893 assert tree.children[1].path == written[0]
894 assert written[1] == request.urlopen(img).read()