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("massage", help="Massaging phase (internal use)")
90 massage_p.add_argument(
91 "--write-commands-to",
94 help="Temporary file path to write commands to",
97 massage_p.add_argument(
100 help="Turn on debugging to stderr of the MIME tree walk",
103 massage_p.add_argument(
106 help="If provided, the script is invoked as editor on the mail draft",
109 return parser.parse_args(*args, **kwargs)
112 # [ MARKDOWN WRAPPING ] #######################################################
115 InlineImageInfo = namedtuple(
116 "InlineImageInfo", ["cid", "desc"], defaults=[None]
120 class InlineImageExtension(Extension):
121 class RelatedImageInlineProcessor(ImageInlineProcessor):
122 def __init__(self, re, md, ext):
123 super().__init__(re, md)
126 def handleMatch(self, m, data):
127 el, start, end = super().handleMatch(m, data)
128 if "src" in el.attrib:
129 src = el.attrib["src"]
130 if "://" not in src or src.startswith("file://"):
131 # We only inline local content
132 cid = self._ext.get_cid_for_image(el.attrib)
133 el.attrib["src"] = f"cid:{cid}"
134 return el, start, end
138 self._images = OrderedDict()
140 def extendMarkdown(self, md):
141 md.registerExtension(self)
142 inline_image_proc = self.RelatedImageInlineProcessor(
143 IMAGE_LINK_RE, md, self
145 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
147 def get_cid_for_image(self, attrib):
148 msgid = make_msgid()[1:-1]
150 if path.startswith("/"):
151 path = f"file://{path}"
152 self._images[path] = InlineImageInfo(
153 msgid, attrib.get("title", attrib.get("alt"))
157 def get_images(self):
161 def markdown_with_inline_image_support(text, *, extensions=None):
162 inline_image_handler = InlineImageExtension()
163 extensions = extensions or []
164 extensions.append(inline_image_handler)
165 mdwn = markdown.Markdown(extensions=extensions)
166 htmltext = mdwn.convert(text)
168 images = inline_image_handler.get_images()
170 def replace_image_with_cid(matchobj):
171 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
173 return f"(cid:{images[m].cid}"
174 return matchobj.group(0)
176 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
177 return text, htmltext, images
180 # [ PARTS GENERATION ] ########################################################
186 ["type", "subtype", "path", "desc", "cid", "orig"],
187 defaults=[None, None, False],
191 ret = f"<{self.type}/{self.subtype}>"
193 ret = f"{ret} cid:{self.cid}"
195 ret = f"{ret} ORIGINAL"
200 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
203 return f"<multipart/{self.subtype}> children={len(self.children)}"
206 def filewriter_fn(path, content, mode="w", **kwargs):
207 with open(path, mode, **kwargs) as out_f:
211 def collect_inline_images(
212 images, *, tempdir=None, filewriter_fn=filewriter_fn
215 for path, info in images.items():
216 data = request.urlopen(path)
218 mimetype = data.headers["Content-Type"]
219 ext = mimetypes.guess_extension(mimetype)
220 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
221 path = pathlib.Path(tempfilename[1])
223 filewriter_fn(path, data.read(), "w+b")
226 Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
232 def convert_markdown_to_html(
236 filewriter_fn=filewriter_fn,
240 origtext, htmltext, images = markdown_with_inline_image_support(
241 origtext, extensions=extensions
244 filewriter_fn(draftpath, origtext, encoding="utf-8")
246 "text", "plain", draftpath, "Plain-text version", orig=True
249 htmlpath = draftpath.with_suffix(".html")
251 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
253 htmlpart = Part("text", "html", htmlpath, "HTML version")
256 "alternative", [textpart, htmlpart], "Group of alternative content"
259 imgparts = collect_inline_images(
260 images, tempdir=tempdir, filewriter_fn=filewriter_fn
264 "relative", [altpart] + imgparts, "Group of related content"
270 class MIMETreeDFWalker:
271 def __init__(self, *, visitor_fn=None, debug=False):
272 self._visitor_fn = visitor_fn
275 def walk(self, root, *, visitor_fn=None):
277 Recursive function to implement a depth-dirst walk of the MIME-tree
281 if isinstance(root, list):
282 root = Multipart("mixed", children=root)
287 visitor_fn=visitor_fn or self._visitor_fn,
290 def _walk(self, node, *, stack, visitor_fn):
291 # Let's start by enumerating the parts at the current level. At the
292 # root level, stack will be the empty list, and we expect a multipart/*
293 # container at this level. Later, e.g. within a mutlipart/alternative
294 # container, the subtree will just be the alternative parts, while the
295 # top of the stack will be the multipart/alternative container, which
296 # we will process after the following loop.
298 lead = f"{'| '*len(stack)}|-"
299 if isinstance(node, Multipart):
301 f"{lead}{node} parents={[s.subtype for s in stack]}"
304 # Depth-first, so push the current container onto the stack,
307 self.debugprint("| " * (len(stack) + 1))
308 for child in node.children:
312 visitor_fn=visitor_fn,
314 self.debugprint("| " * len(stack))
315 assert stack.pop() == node
318 self.debugprint(f"{lead}{node}")
321 visitor_fn(node, stack, debugprint=self.debugprint)
323 def debugprint(self, s, **kwargs):
325 print(s, file=sys.stderr, **kwargs)
328 # [ RUN MODES ] ###############################################################
333 Stupid class to interface writing out Mutt commands. This is quite a hack
334 to deal with the fact that Mutt runs "push" commands in reverse order, so
335 all of a sudden, things become very complicated when mixing with "real"
338 Hence we keep two sets of commands, and one set of pushes. Commands are
339 added to the first until a push is added, after which commands are added to
340 the second set of commands.
342 On flush(), the first set is printed, followed by the pushes in reverse,
343 and then the second set is printed. All 3 sets are then cleared.
346 def __init__(self, out_f=sys.stdout, *, debug=False):
347 self._cmd1, self._push, self._cmd2 = [], [], []
359 s = s.replace('"', '"')
362 self._push.insert(0, s)
366 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
368 self._cmd1, self._push, self._cmd2 = [], [], []
370 def debugprint(self, s, **kwargs):
372 print(s, file=sys.stderr, **kwargs)
380 debug_commands=False,
382 temppath = temppath or pathlib.Path(
383 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
385 cmds = MuttCommands(out_f, debug=debug_commands)
387 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
389 cmds.cmd('set my_editor="$editor"')
390 cmds.cmd('set my_edit_headers="$edit_headers"')
391 cmds.cmd(f'set editor="{editor}"')
392 cmds.cmd("unset edit_headers")
393 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
394 cmds.push("<first-entry><edit-file>")
404 converter=convert_markdown_to_html,
407 debug_commands=False,
410 # Here's the big picture: we're being invoked as the editor on the email
411 # draft, and whatever commands we write to the file given as cmdpath will
412 # be run by the second source command in the macro definition.
414 # Let's start by cleaning up what the setup did (see above), i.e. we
415 # restore the $editor and $edit_headers variables, and also unset the
416 # variable used to identify the command file we're currently writing
418 cmds = MuttCommands(cmd_f, debug=debug_commands)
419 cmds.cmd('set editor="$my_editor"')
420 cmds.cmd('set edit_headers="$my_edit_headers"')
421 cmds.cmd("unset my_editor")
422 cmds.cmd("unset my_edit_headers")
424 # let's flush those commands, as there'll be a lot of pushes from now
425 # on, which need to be run in reverse order
428 extensions = extensions.split(",") if extensions else []
429 tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
431 mimetree = MIMETreeDFWalker(debug=debug_walk)
433 def visitor_fn(item, stack, *, debugprint=None):
435 Visitor function called for every node (part) of the MIME tree,
436 depth-first, and responsible for telling NeoMutt how to assemble
441 if isinstance(item, Part):
442 # We've hit a leaf-node, i.e. an alternative or a related part
443 # with actual content.
447 # The original source already exists in the NeoMutt tree, but
448 # the underlying file may have been modified, so we need to
449 # update the encoding, but that's it:
450 cmds.push("<update-encoding>")
452 # … whereas all other parts need to be added, and they're all
453 # considered to be temporary and inline:
454 cmds.push(f"<attach-file>{item.path}<enter>")
455 cmds.push("<toggle-unlink><toggle-disposition>")
457 # If the item (including the original) comes with additional
458 # information, then we might just as well update the NeoMutt
461 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
463 elif isinstance(item, Multipart):
464 # This node has children, but we already visited them (see
465 # above), and so they have been tagged in NeoMutt's compose
466 # window. Now it's just a matter of telling NeoMutt to do the
467 # appropriate grouping:
468 if item.subtype == "alternative":
469 cmds.push("<group-alternatives>")
470 elif item.subtype in ("relative", "related"):
471 cmds.push("<group-related>")
472 elif item.subtype == "multilingual":
473 cmds.push("<group-multilingual>")
476 # We should never get here
477 assert not "is valid part"
479 # If the item has a description, we might just as well add it
481 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
483 # Finally, if we're at non-root level, tag the new container,
484 # as it might itself be part of a container, to be processed
487 cmds.push("<tag-entry>")
492 # Let's walk the tree and visit every node with our fancy visitor
494 mimetree.walk(tree, visitor_fn=visitor_fn)
497 cmds.push("<send-message>")
499 # Finally, cleanup. Since we're responsible for removing the temporary
500 # file, how's this for a little hack?
502 filename = cmd_f.name
503 except AttributeError:
504 filename = "pytest_internal_file"
505 cmds.cmd(f"source 'rm -f {filename}|'")
506 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
510 # [ CLI ENTRY ] ###############################################################
512 if __name__ == "__main__":
513 args = parse_cli_args()
515 if args.mode is None:
517 tempdir=args.tempdir,
518 debug_commands=args.debug_commands,
521 elif args.mode == "massage":
522 with open(args.MAILDRAFT, "r") as draft_f, open(
527 pathlib.Path(args.MAILDRAFT),
529 extensions=args.extensions,
530 only_build=args.only_build,
531 tempdir=args.tempdir,
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
668 def string_io(self, const1, text=None):
669 return StringIO(text or const1)
671 def test_do_massage_basic(self, const1, string_io, capsys):
672 def converter(drafttext, draftpath, extensions, tempdir):
673 return Part("text", "plain", draftpath, orig=True)
682 captured = capsys.readouterr()
683 lines = captured.out.splitlines()
684 assert '="$my_editor"' in lines.pop(0)
685 assert '="$my_edit_headers"' in lines.pop(0)
686 assert "unset my_editor" == lines.pop(0)
687 assert "unset my_edit_headers" == lines.pop(0)
688 assert "send-message" in lines.pop(0)
689 assert "update-encoding" in lines.pop(0)
690 assert "source 'rm -f " in lines.pop(0)
691 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
693 def test_do_massage_fulltree(
694 self, string_io, const1, basic_mime_tree, capsys
696 def converter(drafttext, draftpath, extensions, tempdir):
697 return basic_mime_tree
706 captured = capsys.readouterr()
707 lines = captured.out.splitlines()[4:]
708 assert "send-message" in lines.pop(0)
709 assert "Related" in lines.pop(0)
710 assert "group-related" in lines.pop(0)
711 assert "tag-entry" in lines.pop(0)
712 assert "Logo" in lines.pop(0)
713 assert "content-id" in lines.pop(0)
714 assert "toggle-unlink" in lines.pop(0)
715 assert "logo.png" in lines.pop(0)
716 assert "tag-entry" in lines.pop(0)
717 assert "Alternative" in lines.pop(0)
718 assert "group-alternatives" in lines.pop(0)
719 assert "tag-entry" in lines.pop(0)
720 assert "HTML" in lines.pop(0)
721 assert "toggle-unlink" in lines.pop(0)
722 assert "part.html" in lines.pop(0)
723 assert "tag-entry" in lines.pop(0)
724 assert "Plain" in lines.pop(0)
725 assert "update-encoding" in lines.pop(0)
726 assert len(lines) == 2
729 def fake_filewriter(self):
734 def __call__(self, path, content, mode="w", **kwargs):
735 self._writes.append((path, content))
737 def pop(self, index=-1):
738 return self._writes.pop(index)
743 def markdown_non_converter(self, const1, const2):
744 return lambda s, text: f"{const1}{text}{const2}"
746 def test_converter_tree_basic(
747 self, const1, const2, fake_filewriter, markdown_non_converter
749 path = pathlib.Path(const2)
750 tree = convert_markdown_to_html(
751 const1, path, filewriter_fn=fake_filewriter
754 assert tree.subtype == "alternative"
755 assert len(tree.children) == 2
756 assert tree.children[0].subtype == "plain"
757 assert tree.children[0].path == path
758 assert tree.children[0].orig
759 assert tree.children[1].subtype == "html"
760 assert tree.children[1].path == path.with_suffix(".html")
762 def test_converter_writes(
768 markdown_non_converter,
770 path = pathlib.Path(const2)
772 with monkeypatch.context() as m:
773 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
774 convert_markdown_to_html(
775 const1, path, filewriter_fn=fake_filewriter
778 assert (path, const1) == fake_filewriter.pop(0)
780 path.with_suffix(".html"),
781 markdown_non_converter(None, const1),
782 ) == fake_filewriter.pop(0)
784 def test_markdown_inline_image_processor(self):
785 imgpath1 = "file:/path/to/image.png"
786 imgpath2 = "file:///path/to/image.png?url=params"
787 imgpath3 = "/path/to/image.png"
788 text = f"""![inline local image]({imgpath1})
790 with newline]({imgpath2})
791 ![image local path]({imgpath3})"""
792 text, html, images = markdown_with_inline_image_support(text)
794 # local paths have been normalised to URLs:
795 imgpath3 = f"file://{imgpath3}"
797 assert 'src="cid:' in html
798 assert "](cid:" in text
799 assert len(images) == 3
800 assert imgpath1 in images
801 assert imgpath2 in images
802 assert imgpath3 in images
803 assert images[imgpath1].cid != images[imgpath2].cid
804 assert images[imgpath1].cid != images[imgpath3].cid
805 assert images[imgpath2].cid != images[imgpath3].cid
807 def test_markdown_inline_image_processor_title_to_desc(self, const1):
808 imgpath = "file:///path/to/image.png"
809 text = f'![inline local image]({imgpath} "{const1}")'
810 text, html, images = markdown_with_inline_image_support(text)
811 assert images[imgpath].desc == const1
813 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
814 imgpath = "file:///path/to/image.png"
815 text = f"![{const1}]({imgpath})"
816 text, html, images = markdown_with_inline_image_support(text)
817 assert images[imgpath].desc == const1
819 def test_markdown_inline_image_processor_title_over_alt_desc(
822 imgpath = "file:///path/to/image.png"
823 text = f'![{const1}]({imgpath} "{const2}")'
824 text, html, images = markdown_with_inline_image_support(text)
825 assert images[imgpath].desc == const2
827 def test_markdown_inline_image_not_external(self):
828 imgpath = "https://path/to/image.png"
829 text = f"![inline image]({imgpath})"
830 text, html, images = markdown_with_inline_image_support(text)
832 assert 'src="cid:' not in html
833 assert "](cid:" not in text
834 assert len(images) == 0
836 def test_markdown_inline_image_local_file(self):
837 imgpath = "/path/to/image.png"
838 text = f"![inline image]({imgpath})"
839 text, html, images = markdown_with_inline_image_support(text)
841 for k, v in images.items():
842 assert k == f"file://{imgpath}"
848 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
849 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
852 def test_markdown_inline_image_processor_base64(self, test_png):
853 text = f"![1px white inlined]({test_png})"
854 text, html, images = markdown_with_inline_image_support(text)
856 assert 'src="cid:' in html
857 assert "](cid:" in text
858 assert len(images) == 1
859 assert test_png in images
861 def test_converter_tree_inline_image_base64(
862 self, test_png, const1, fake_filewriter
864 text = f"![inline base64 image]({test_png})"
865 path = pathlib.Path(const1)
866 tree = convert_markdown_to_html(
867 text, path, filewriter_fn=fake_filewriter
870 assert tree.subtype == "relative"
871 assert tree.children[1].subtype == "png"
872 written = fake_filewriter.pop()
873 assert tree.children[1].path == written[0]
874 assert written[1] == request.urlopen(test_png).read()
876 def test_inline_image_collection(
877 self, test_png, const1, const2, fake_filewriter
879 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
880 relparts = collect_inline_images(
881 test_images, filewriter_fn=fake_filewriter
884 written = fake_filewriter.pop()
885 assert b"PNG" in written[1]
887 assert relparts[0].subtype == "png"
888 assert relparts[0].path == written[0]
889 assert relparts[0].cid == const1
890 assert relparts[0].desc.endswith(const2)