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):
9 # set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
11 # <enter-command> source '$my_confdir/buildmimetree.py \
12 # --tempdir $tempdir --extensions $my_mdwn_extensions|'<enter>\
13 # <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
14 # " "Convert message into a modern MIME tree with inline images"
16 # (Yes, we need to call source twice, as mutt only starts to process output
17 # from a source command when the command exits, and since we need to react
18 # to the output, we need to be invoked again, using a $my_ variable to pass
27 # - Pygments, if installed, then syntax highlighting is enabled
30 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
32 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
33 # Released under the GPL-2+ licence, just like Mutt itself.
43 from collections import namedtuple, OrderedDict
44 from markdown.extensions import Extension
45 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
46 from email.utils import make_msgid
47 from urllib import request
50 def parse_cli_args(*args, **kwargs):
51 parser = argparse.ArgumentParser(
53 "NeoMutt helper to turn text/markdown email parts "
54 "into full-fledged MIME trees"
58 "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
59 "Released under the MIT licence"
66 help="Markdown extension to use (comma-separated list)",
72 help="Only build, don't send the message",
78 help="Specify temporary directory to use for attachments",
84 help="Turn on debug logging of commands generated to stderr",
87 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
88 massage_p = subp.add_parser(
89 "massage", help="Massaging phase (internal use)"
92 massage_p.add_argument(
93 "--write-commands-to",
96 help="Temporary file path to write commands to",
99 massage_p.add_argument(
102 help="Turn on debugging to stderr of the MIME tree walk",
105 massage_p.add_argument(
108 help="If provided, the script is invoked as editor on the mail draft",
111 return parser.parse_args(*args, **kwargs)
114 # [ MARKDOWN WRAPPING ] #######################################################
117 InlineImageInfo = namedtuple(
118 "InlineImageInfo", ["cid", "desc"], defaults=[None]
122 class InlineImageExtension(Extension):
123 class RelatedImageInlineProcessor(ImageInlineProcessor):
124 def __init__(self, re, md, ext):
125 super().__init__(re, md)
128 def handleMatch(self, m, data):
129 el, start, end = super().handleMatch(m, data)
130 if "src" in el.attrib:
131 src = el.attrib["src"]
132 if "://" not in src or src.startswith("file://"):
133 # We only inline local content
134 cid = self._ext.get_cid_for_image(el.attrib)
135 el.attrib["src"] = f"cid:{cid}"
136 return el, start, end
140 self._images = OrderedDict()
142 def extendMarkdown(self, md):
143 md.registerExtension(self)
144 inline_image_proc = self.RelatedImageInlineProcessor(
145 IMAGE_LINK_RE, md, self
147 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
149 def get_cid_for_image(self, attrib):
150 msgid = make_msgid()[1:-1]
152 if path.startswith("/"):
153 path = f"file://{path}"
154 self._images[path] = InlineImageInfo(
155 msgid, attrib.get("title", attrib.get("alt"))
159 def get_images(self):
163 def markdown_with_inline_image_support(text, *, extensions=None):
164 inline_image_handler = InlineImageExtension()
165 extensions = extensions or []
166 extensions.append(inline_image_handler)
167 mdwn = markdown.Markdown(extensions=extensions)
168 htmltext = mdwn.convert(text)
170 images = inline_image_handler.get_images()
172 def replace_image_with_cid(matchobj):
173 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
175 return f"(cid:{images[m].cid}"
176 return matchobj.group(0)
178 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
179 return text, htmltext, images
182 # [ PARTS GENERATION ] ########################################################
188 ["type", "subtype", "path", "desc", "cid", "orig"],
189 defaults=[None, None, False],
193 ret = f"<{self.type}/{self.subtype}>"
195 ret = f"{ret} cid:{self.cid}"
197 ret = f"{ret} ORIGINAL"
202 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
205 return f"<multipart/{self.subtype}> children={len(self.children)}"
208 def filewriter_fn(path, content, mode="w", **kwargs):
209 with open(path, mode, **kwargs) as out_f:
213 def collect_inline_images(
214 images, *, tempdir=None, filewriter_fn=filewriter_fn
217 for path, info in images.items():
218 data = request.urlopen(path)
220 mimetype = data.headers["Content-Type"]
221 ext = mimetypes.guess_extension(mimetype)
222 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
223 path = pathlib.Path(tempfilename[1])
225 filewriter_fn(path, data.read(), "w+b")
229 *mimetype.split("/"),
232 desc=f"Image: {info.desc}",
239 def convert_markdown_to_html(
243 filewriter_fn=filewriter_fn,
247 origtext, htmltext, images = markdown_with_inline_image_support(
248 origtext, extensions=extensions
251 filewriter_fn(draftpath, origtext, encoding="utf-8")
253 "text", "plain", draftpath, "Plain-text version", orig=True
256 htmlpath = draftpath.with_suffix(".html")
258 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
260 htmlpart = Part("text", "html", htmlpath, "HTML version")
263 "alternative", [textpart, htmlpart], "Group of alternative content"
266 imgparts = collect_inline_images(
267 images, tempdir=tempdir, filewriter_fn=filewriter_fn
271 "relative", [altpart] + imgparts, "Group of related content"
277 class MIMETreeDFWalker:
278 def __init__(self, *, visitor_fn=None, debug=False):
279 self._visitor_fn = visitor_fn
282 def walk(self, root, *, visitor_fn=None):
284 Recursive function to implement a depth-dirst walk of the MIME-tree
288 if isinstance(root, list):
289 root = Multipart("mixed", children=root)
294 visitor_fn=visitor_fn or self._visitor_fn,
297 def _walk(self, node, *, stack, visitor_fn):
298 # Let's start by enumerating the parts at the current level. At the
299 # root level, stack will be the empty list, and we expect a multipart/*
300 # container at this level. Later, e.g. within a mutlipart/alternative
301 # container, the subtree will just be the alternative parts, while the
302 # top of the stack will be the multipart/alternative container, which
303 # we will process after the following loop.
305 lead = f"{'| '*len(stack)}|-"
306 if isinstance(node, Multipart):
308 f"{lead}{node} parents={[s.subtype for s in stack]}"
311 # Depth-first, so push the current container onto the stack,
314 self.debugprint("| " * (len(stack) + 1))
315 for child in node.children:
319 visitor_fn=visitor_fn,
321 self.debugprint("| " * len(stack))
322 assert stack.pop() == node
325 self.debugprint(f"{lead}{node}")
328 visitor_fn(node, stack, debugprint=self.debugprint)
330 def debugprint(self, s, **kwargs):
332 print(s, file=sys.stderr, **kwargs)
335 # [ RUN MODES ] ###############################################################
340 Stupid class to interface writing out Mutt commands. This is quite a hack
341 to deal with the fact that Mutt runs "push" commands in reverse order, so
342 all of a sudden, things become very complicated when mixing with "real"
345 Hence we keep two sets of commands, and one set of pushes. Commands are
346 added to the first until a push is added, after which commands are added to
347 the second set of commands.
349 On flush(), the first set is printed, followed by the pushes in reverse,
350 and then the second set is printed. All 3 sets are then cleared.
353 def __init__(self, out_f=sys.stdout, *, debug=False):
354 self._cmd1, self._push, self._cmd2 = [], [], []
366 s = s.replace('"', '"')
369 self._push.insert(0, s)
373 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
375 self._cmd1, self._push, self._cmd2 = [], [], []
377 def debugprint(self, s, **kwargs):
379 print(s, file=sys.stderr, **kwargs)
387 debug_commands=False,
389 temppath = temppath or pathlib.Path(
390 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
392 cmds = MuttCommands(out_f, debug=debug_commands)
394 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
396 cmds.cmd('set my_editor="$editor"')
397 cmds.cmd('set my_edit_headers="$edit_headers"')
398 cmds.cmd(f'set editor="{editor}"')
399 cmds.cmd("unset edit_headers")
400 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
401 cmds.push("<first-entry><edit-file>")
411 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 []
441 extensions=extensions,
444 mimetree = MIMETreeDFWalker(debug=debug_walk)
446 def visitor_fn(item, stack, *, debugprint=None):
448 Visitor function called for every node (part) of the MIME tree,
449 depth-first, and responsible for telling NeoMutt how to assemble
452 KILL_LINE = r"\Ca\Ck"
454 if isinstance(item, Part):
455 # We've hit a leaf-node, i.e. an alternative or a related part
456 # with actual content.
460 # The original source already exists in the NeoMutt tree, but
461 # the underlying file may have been modified, so we need to
462 # update the encoding, but that's it:
463 cmds.push("<update-encoding>")
465 # … whereas all other parts need to be added, and they're all
466 # considered to be temporary and inline:
467 cmds.push(f"<attach-file>{item.path}<enter>")
468 cmds.push("<toggle-unlink><toggle-disposition>")
470 # If the item (including the original) comes with additional
471 # information, then we might just as well update the NeoMutt
474 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
476 elif isinstance(item, Multipart):
477 # This node has children, but we already visited them (see
478 # above), and so they have been tagged in NeoMutt's compose
479 # window. Now it's just a matter of telling NeoMutt to do the
480 # appropriate grouping:
481 if item.subtype == "alternative":
482 cmds.push("<group-alternatives>")
483 elif item.subtype in ("relative", "related"):
484 cmds.push("<group-related>")
485 elif item.subtype == "multilingual":
486 cmds.push("<group-multilingual>")
489 # We should never get here
490 assert not "is valid part"
492 # If the item has a description, we might just as well add it
494 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
496 # Finally, if we're at non-root level, tag the new container,
497 # as it might itself be part of a container, to be processed
500 cmds.push("<tag-entry>")
505 # Let's walk the tree and visit every node with our fancy visitor
507 mimetree.walk(tree, visitor_fn=visitor_fn)
510 cmds.push("<send-message>")
512 # Finally, cleanup. Since we're responsible for removing the temporary
513 # file, how's this for a little hack?
515 filename = cmd_f.name
516 except AttributeError:
517 filename = "pytest_internal_file"
518 cmds.cmd(f"source 'rm -f {filename}|'")
519 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
523 # [ CLI ENTRY ] ###############################################################
525 if __name__ == "__main__":
526 args = parse_cli_args()
528 if args.mode is None:
530 tempdir=args.tempdir,
531 debug_commands=args.debug_commands,
534 elif args.mode == "massage":
535 with open(args.MAILDRAFT, "r") as draft_f, open(
540 pathlib.Path(args.MAILDRAFT),
542 extensions=args.extensions,
543 only_build=args.only_build,
544 tempdir=args.tempdir,
545 debug_commands=args.debug_commands,
546 debug_walk=args.debug_walk,
550 # [ TESTS ] ###################################################################
554 from io import StringIO
559 return "CONSTANT STRING 1"
563 return "CONSTANT STRING 2"
565 # NOTE: tests using the capsys fixture must specify sys.stdout to the
566 # functions they call, else old stdout is used and not captured
568 def test_MuttCommands_cmd(self, const1, const2, capsys):
569 "Assert order of commands"
570 cmds = MuttCommands(out_f=sys.stdout)
574 captured = capsys.readouterr()
575 assert captured.out == "\n".join((const1, const2, ""))
577 def test_MuttCommands_push(self, const1, const2, capsys):
578 "Assert reverse order of pushes"
579 cmds = MuttCommands(out_f=sys.stdout)
583 captured = capsys.readouterr()
586 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
589 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
590 "Assert reverse order of pushes"
591 cmds = MuttCommands(out_f=sys.stdout)
592 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
594 cmds.cmd(lines[4 * i + 0])
595 cmds.cmd(lines[4 * i + 1])
596 cmds.push(lines[4 * i + 2])
597 cmds.push(lines[4 * i + 3])
600 captured = capsys.readouterr()
601 lines_out = captured.out.splitlines()
602 assert lines[0] in lines_out[0]
603 assert lines[1] in lines_out[1]
604 assert lines[7] in lines_out[2]
605 assert lines[6] in lines_out[3]
606 assert lines[3] in lines_out[4]
607 assert lines[2] in lines_out[5]
608 assert lines[4] in lines_out[6]
609 assert lines[5] in lines_out[7]
612 def basic_mime_tree(self):
626 Part("text", "html", "part.html", desc="HTML"),
631 "text", "png", "logo.png", cid="logo.png", desc="Logo"
637 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
638 mimetree = MIMETreeDFWalker()
642 def visitor_fn(item, stack, debugprint):
643 items.append((item, len(stack)))
645 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
646 assert len(items) == 5
647 assert items[0][0].subtype == "plain"
648 assert items[0][1] == 2
649 assert items[1][0].subtype == "html"
650 assert items[1][1] == 2
651 assert items[2][0].subtype == "alternative"
652 assert items[2][1] == 1
653 assert items[3][0].subtype == "png"
654 assert items[3][1] == 1
655 assert items[4][0].subtype == "relative"
656 assert items[4][1] == 0
658 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
659 mimetree = MIMETreeDFWalker()
662 def visitor_fn(item, stack, debugprint):
665 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
666 assert items[-1].subtype == "mixed"
668 def test_MIMETreeDFWalker_visitor_in_constructor(
669 self, basic_mime_tree
673 def visitor_fn(item, stack, debugprint):
676 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
677 mimetree.walk(basic_mime_tree)
678 assert len(items) == 5
681 def string_io(self, const1, text=None):
682 return StringIO(text or const1)
684 def test_do_massage_basic(self, const1, string_io, capsys):
685 def converter(drafttext, draftpath, extensions, tempdir):
686 return Part("text", "plain", draftpath, orig=True)
695 captured = capsys.readouterr()
696 lines = captured.out.splitlines()
697 assert '="$my_editor"' in lines.pop(0)
698 assert '="$my_edit_headers"' in lines.pop(0)
699 assert "unset my_editor" == lines.pop(0)
700 assert "unset my_edit_headers" == lines.pop(0)
701 assert "send-message" in lines.pop(0)
702 assert "update-encoding" in lines.pop(0)
703 assert "source 'rm -f " in lines.pop(0)
704 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
706 def test_do_massage_fulltree(
707 self, string_io, const1, basic_mime_tree, capsys
709 def converter(drafttext, draftpath, extensions, tempdir):
710 return basic_mime_tree
719 captured = capsys.readouterr()
720 lines = captured.out.splitlines()[4:]
721 assert "send-message" in lines.pop(0)
722 assert "Related" in lines.pop(0)
723 assert "group-related" in lines.pop(0)
724 assert "tag-entry" in lines.pop(0)
725 assert "Logo" in lines.pop(0)
726 assert "content-id" in lines.pop(0)
727 assert "toggle-unlink" in lines.pop(0)
728 assert "logo.png" in lines.pop(0)
729 assert "tag-entry" in lines.pop(0)
730 assert "Alternative" in lines.pop(0)
731 assert "group-alternatives" in lines.pop(0)
732 assert "tag-entry" in lines.pop(0)
733 assert "HTML" in lines.pop(0)
734 assert "toggle-unlink" in lines.pop(0)
735 assert "part.html" in lines.pop(0)
736 assert "tag-entry" in lines.pop(0)
737 assert "Plain" in lines.pop(0)
738 assert "update-encoding" in lines.pop(0)
739 assert len(lines) == 2
742 def fake_filewriter(self):
747 def __call__(self, path, content, mode="w", **kwargs):
748 self._writes.append((path, content))
750 def pop(self, index=-1):
751 return self._writes.pop(index)
756 def markdown_non_converter(self, const1, const2):
757 return lambda s, text: f"{const1}{text}{const2}"
759 def test_converter_tree_basic(
760 self, const1, const2, fake_filewriter, markdown_non_converter
762 path = pathlib.Path(const2)
763 tree = convert_markdown_to_html(
764 const1, path, filewriter_fn=fake_filewriter
767 assert tree.subtype == "alternative"
768 assert len(tree.children) == 2
769 assert tree.children[0].subtype == "plain"
770 assert tree.children[0].path == path
771 assert tree.children[0].orig
772 assert tree.children[1].subtype == "html"
773 assert tree.children[1].path == path.with_suffix(".html")
775 def test_converter_writes(
781 markdown_non_converter,
783 path = pathlib.Path(const2)
785 with monkeypatch.context() as m:
786 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
787 convert_markdown_to_html(
788 const1, path, filewriter_fn=fake_filewriter
791 assert (path, const1) == fake_filewriter.pop(0)
793 path.with_suffix(".html"),
794 markdown_non_converter(None, const1),
795 ) == fake_filewriter.pop(0)
797 def test_markdown_inline_image_processor(self):
798 imgpath1 = "file:/path/to/image.png"
799 imgpath2 = "file:///path/to/image.png?url=params"
800 imgpath3 = "/path/to/image.png"
801 text = f"""![inline local image]({imgpath1})
803 with newline]({imgpath2})
804 ![image local path]({imgpath3})"""
805 text, html, images = markdown_with_inline_image_support(text)
807 # local paths have been normalised to URLs:
808 imgpath3 = f"file://{imgpath3}"
810 assert 'src="cid:' in html
811 assert "](cid:" in text
812 assert len(images) == 3
813 assert imgpath1 in images
814 assert imgpath2 in images
815 assert imgpath3 in images
816 assert images[imgpath1].cid != images[imgpath2].cid
817 assert images[imgpath1].cid != images[imgpath3].cid
818 assert images[imgpath2].cid != images[imgpath3].cid
820 def test_markdown_inline_image_processor_title_to_desc(self, const1):
821 imgpath = "file:///path/to/image.png"
822 text = f'![inline local image]({imgpath} "{const1}")'
823 text, html, images = markdown_with_inline_image_support(text)
824 assert images[imgpath].desc == const1
826 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
827 imgpath = "file:///path/to/image.png"
828 text = f"![{const1}]({imgpath})"
829 text, html, images = markdown_with_inline_image_support(text)
830 assert images[imgpath].desc == const1
832 def test_markdown_inline_image_processor_title_over_alt_desc(
835 imgpath = "file:///path/to/image.png"
836 text = f'![{const1}]({imgpath} "{const2}")'
837 text, html, images = markdown_with_inline_image_support(text)
838 assert images[imgpath].desc == const2
840 def test_markdown_inline_image_not_external(self):
841 imgpath = "https://path/to/image.png"
842 text = f"![inline image]({imgpath})"
843 text, html, images = markdown_with_inline_image_support(text)
845 assert 'src="cid:' not in html
846 assert "](cid:" not in text
847 assert len(images) == 0
849 def test_markdown_inline_image_local_file(self):
850 imgpath = "/path/to/image.png"
851 text = f"![inline image]({imgpath})"
852 text, html, images = markdown_with_inline_image_support(text)
854 for k, v in images.items():
855 assert k == f"file://{imgpath}"
861 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
862 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
865 def test_markdown_inline_image_processor_base64(self, test_png):
866 text = f"![1px white inlined]({test_png})"
867 text, html, images = markdown_with_inline_image_support(text)
869 assert 'src="cid:' in html
870 assert "](cid:" in text
871 assert len(images) == 1
872 assert test_png in images
874 def test_converter_tree_inline_image_base64(
875 self, test_png, const1, fake_filewriter
877 text = f"![inline base64 image]({test_png})"
878 path = pathlib.Path(const1)
879 tree = convert_markdown_to_html(
880 text, path, filewriter_fn=fake_filewriter
883 assert tree.subtype == "relative"
884 assert tree.children[1].subtype == "png"
885 written = fake_filewriter.pop()
886 assert tree.children[1].path == written[0]
887 assert written[1] == request.urlopen(test_png).read()
889 def test_inline_image_collection(
890 self, test_png, const1, const2, fake_filewriter
892 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
893 relparts = collect_inline_images(
894 test_images, filewriter_fn=fake_filewriter
897 written = fake_filewriter.pop()
898 assert b"PNG" in written[1]
900 assert relparts[0].subtype == "png"
901 assert relparts[0].path == written[0]
902 assert relparts[0].cid == const1
903 assert relparts[0].desc.endswith(const2)