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 --tempdir $tempdir|'<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(
72 help="Markdown extension to add to the list of extensions use",
75 parser_setup.add_argument(
78 help="Only build, don't send the message",
81 parser_setup.add_argument(
84 help="Specify temporary directory to use for attachments",
87 parser_setup.add_argument(
90 help="Turn on debug logging of commands generated to stderr",
93 parser_massage.add_argument(
94 "--write-commands-to",
97 help="Temporary file path to write commands to",
100 parser_massage.add_argument(
102 metavar="EXTENSIONS",
105 help="Markdown extension to use (comma-separated list)",
108 parser_massage.add_argument(
111 help="Only build, don't send the message",
114 parser_massage.add_argument(
117 help="Specify temporary directory to use for attachments",
120 parser_massage.add_argument(
123 help="Turn on debug logging of commands generated to stderr",
126 parser_massage.add_argument(
129 help="Turn on debugging to stderr of the MIME tree walk",
132 parser_massage.add_argument(
135 help="If provided, the script is invoked as editor on the mail draft",
138 return parser.parse_args(*args, **kwargs)
141 # [ MARKDOWN WRAPPING ] #######################################################
144 InlineImageInfo = namedtuple(
145 "InlineImageInfo", ["cid", "desc"], defaults=[None]
149 class InlineImageExtension(Extension):
150 class RelatedImageInlineProcessor(ImageInlineProcessor):
151 def __init__(self, re, md, ext):
152 super().__init__(re, md)
155 def handleMatch(self, m, data):
156 el, start, end = super().handleMatch(m, data)
157 if "src" in el.attrib:
158 src = el.attrib["src"]
159 if "://" not in src or src.startswith("file://"):
160 # We only inline local content
161 cid = self._ext.get_cid_for_image(el.attrib)
162 el.attrib["src"] = f"cid:{cid}"
163 return el, start, end
167 self._images = OrderedDict()
169 def extendMarkdown(self, md):
170 md.registerExtension(self)
171 inline_image_proc = self.RelatedImageInlineProcessor(
172 IMAGE_LINK_RE, md, self
174 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
176 def get_cid_for_image(self, attrib):
177 msgid = make_msgid()[1:-1]
179 if path.startswith("/"):
180 path = f"file://{path}"
181 self._images[path] = InlineImageInfo(
182 msgid, attrib.get("title", attrib.get("alt"))
186 def get_images(self):
190 def markdown_with_inline_image_support(text, *, extensions=None):
191 inline_image_handler = InlineImageExtension()
192 extensions = extensions or []
193 extensions.append(inline_image_handler)
194 mdwn = markdown.Markdown(extensions=extensions)
195 htmltext = mdwn.convert(text)
197 images = inline_image_handler.get_images()
199 def replace_image_with_cid(matchobj):
200 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
202 return f"(cid:{images[m].cid}"
203 return matchobj.group(0)
205 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
206 return text, htmltext, images
209 # [ PARTS GENERATION ] ########################################################
215 ["type", "subtype", "path", "desc", "cid", "orig"],
216 defaults=[None, None, False],
220 ret = f"<{self.type}/{self.subtype}>"
222 ret = f"{ret} cid:{self.cid}"
224 ret = f"{ret} ORIGINAL"
229 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
232 return f"<multipart/{self.subtype}> children={len(self.children)}"
235 def filewriter_fn(path, content, mode="w", **kwargs):
236 with open(path, mode, **kwargs) as out_f:
240 def collect_inline_images(
241 images, *, tempdir=None, filewriter_fn=filewriter_fn
244 for path, info in images.items():
245 data = request.urlopen(path)
247 mimetype = data.headers["Content-Type"]
248 ext = mimetypes.guess_extension(mimetype)
249 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
250 path = pathlib.Path(tempfilename[1])
252 filewriter_fn(path, data.read(), "w+b")
255 Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
261 def convert_markdown_to_html(
265 filewriter_fn=filewriter_fn,
269 origtext, htmltext, images = markdown_with_inline_image_support(
270 origtext, extensions=extensions
273 filewriter_fn(draftpath, origtext, encoding="utf-8")
275 "text", "plain", draftpath, "Plain-text version", orig=True
278 htmlpath = draftpath.with_suffix(".html")
280 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
282 htmlpart = Part("text", "html", htmlpath, "HTML version")
285 "alternative", [textpart, htmlpart], "Group of alternative content"
288 imgparts = collect_inline_images(
289 images, tempdir=tempdir, filewriter_fn=filewriter_fn
293 "relative", [altpart] + imgparts, "Group of related content"
299 class MIMETreeDFWalker:
300 def __init__(self, *, visitor_fn=None, debug=False):
301 self._visitor_fn = visitor_fn
304 def walk(self, root, *, visitor_fn=None):
306 Recursive function to implement a depth-dirst walk of the MIME-tree
310 if isinstance(root, list):
311 root = Multipart("mixed", children=root)
316 visitor_fn=visitor_fn or self._visitor_fn,
319 def _walk(self, node, *, stack, visitor_fn):
320 # Let's start by enumerating the parts at the current level. At the
321 # root level, stack will be the empty list, and we expect a multipart/*
322 # container at this level. Later, e.g. within a mutlipart/alternative
323 # container, the subtree will just be the alternative parts, while the
324 # top of the stack will be the multipart/alternative container, which
325 # we will process after the following loop.
327 lead = f"{'| '*len(stack)}|-"
328 if isinstance(node, Multipart):
330 f"{lead}{node} parents={[s.subtype for s in stack]}"
333 # Depth-first, so push the current container onto the stack,
336 self.debugprint("| " * (len(stack) + 1))
337 for child in node.children:
341 visitor_fn=visitor_fn,
343 self.debugprint("| " * len(stack))
344 assert stack.pop() == node
347 self.debugprint(f"{lead}{node}")
350 visitor_fn(node, stack, debugprint=self.debugprint)
352 def debugprint(self, s, **kwargs):
354 print(s, file=sys.stderr, **kwargs)
357 # [ RUN MODES ] ###############################################################
362 Stupid class to interface writing out Mutt commands. This is quite a hack
363 to deal with the fact that Mutt runs "push" commands in reverse order, so
364 all of a sudden, things become very complicated when mixing with "real"
367 Hence we keep two sets of commands, and one set of pushes. Commands are
368 added to the first until a push is added, after which commands are added to
369 the second set of commands.
371 On flush(), the first set is printed, followed by the pushes in reverse,
372 and then the second set is printed. All 3 sets are then cleared.
375 def __init__(self, out_f=sys.stdout, *, debug=False):
376 self._cmd1, self._push, self._cmd2 = [], [], []
388 s = s.replace('"', '"')
391 self._push.insert(0, s)
395 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
397 self._cmd1, self._push, self._cmd2 = [], [], []
399 def debugprint(self, s, **kwargs):
401 print(s, file=sys.stderr, **kwargs)
411 debug_commands=False,
413 extensions = extensions or []
414 temppath = temppath or pathlib.Path(
415 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
417 cmds = MuttCommands(out_f, debug=debug_commands)
419 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
421 editor = f'{editor} --extensions {",".join(extensions)}'
423 editor = f'{editor} --only-build'
425 editor = f"{editor} --tempdir {tempdir}"
427 editor = f"{editor} --debug-commands"
429 cmds.cmd('set my_editor="$editor"')
430 cmds.cmd('set my_edit_headers="$edit_headers"')
431 cmds.cmd(f'set editor="{editor}"')
432 cmds.cmd("unset edit_headers")
433 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
434 cmds.push("<first-entry><edit-file>")
444 converter=convert_markdown_to_html,
447 debug_commands=False,
450 # Here's the big picture: we're being invoked as the editor on the email
451 # draft, and whatever commands we write to the file given as cmdpath will
452 # be run by the second source command in the macro definition.
454 # Let's start by cleaning up what the setup did (see above), i.e. we
455 # restore the $editor and $edit_headers variables, and also unset the
456 # variable used to identify the command file we're currently writing
458 cmds = MuttCommands(cmd_f, debug=debug_commands)
459 cmds.cmd('set editor="$my_editor"')
460 cmds.cmd('set edit_headers="$my_edit_headers"')
461 cmds.cmd("unset my_editor")
462 cmds.cmd("unset my_edit_headers")
464 # let's flush those commands, as there'll be a lot of pushes from now
465 # on, which need to be run in reverse order
468 extensions = extensions.split(",") if extensions else []
469 tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
471 mimetree = MIMETreeDFWalker(debug=debug_walk)
473 def visitor_fn(item, stack, *, debugprint=None):
475 Visitor function called for every node (part) of the MIME tree,
476 depth-first, and responsible for telling NeoMutt how to assemble
479 if isinstance(item, Part):
480 # We've hit a leaf-node, i.e. an alternative or a related part
481 # with actual content.
485 # The original source already exists in the NeoMutt tree, but
486 # the underlying file may have been modified, so we need to
487 # update the encoding, but that's it:
488 cmds.push("<update-encoding>")
490 # … whereas all other parts need to be added, and they're all
491 # considered to be temporary and inline:
492 cmds.push(f"<attach-file>{item.path}<enter>")
493 cmds.push("<toggle-unlink><toggle-disposition>")
495 # If the item (including the original) comes with additional
496 # information, then we might just as well update the NeoMutt
499 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
501 elif isinstance(item, Multipart):
502 # This node has children, but we already visited them (see
503 # above), and so they have been tagged in NeoMutt's compose
504 # window. Now it's just a matter of telling NeoMutt to do the
505 # appropriate grouping:
506 if item.subtype == "alternative":
507 cmds.push("<group-alternatives>")
508 elif item.subtype in ("relative", "related"):
509 cmds.push("<group-related>")
510 elif item.subtype == "multilingual":
511 cmds.push("<group-multilingual>")
514 # We should never get here
515 assert not "is valid part"
517 # If the item has a description, we might just as well add it
519 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
521 # Finally, if we're at non-root level, tag the new container,
522 # as it might itself be part of a container, to be processed
525 cmds.push("<tag-entry>")
530 # Let's walk the tree and visit every node with our fancy visitor
532 mimetree.walk(tree, visitor_fn=visitor_fn)
535 cmds.push("<send-message>")
537 # Finally, cleanup. Since we're responsible for removing the temporary
538 # file, how's this for a little hack?
540 filename = cmd_f.name
541 except AttributeError:
542 filename = "pytest_internal_file"
543 cmds.cmd(f"source 'rm -f {filename}|'")
544 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
548 # [ CLI ENTRY ] ###############################################################
550 if __name__ == "__main__":
551 args = parse_cli_args()
553 if args.mode == "setup":
556 only_build=args.only_build,
557 tempdir=args.tempdir,
558 debug_commands=args.debug_commands,
561 elif args.mode == "massage":
562 with open(args.MAILDRAFT, "r") as draft_f, open(
567 pathlib.Path(args.MAILDRAFT),
569 extensions=args.extensions,
570 only_build=args.only_build,
571 tempdir=args.tempdir,
572 debug_commands=args.debug_commands,
573 debug_walk=args.debug_walk,
577 # [ TESTS ] ###################################################################
581 from io import StringIO
586 return "CONSTANT STRING 1"
590 return "CONSTANT STRING 2"
592 # NOTE: tests using the capsys fixture must specify sys.stdout to the
593 # functions they call, else old stdout is used and not captured
595 def test_MuttCommands_cmd(self, const1, const2, capsys):
596 "Assert order of commands"
597 cmds = MuttCommands(out_f=sys.stdout)
601 captured = capsys.readouterr()
602 assert captured.out == "\n".join((const1, const2, ""))
604 def test_MuttCommands_push(self, const1, const2, capsys):
605 "Assert reverse order of pushes"
606 cmds = MuttCommands(out_f=sys.stdout)
610 captured = capsys.readouterr()
613 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
616 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
617 "Assert reverse order of pushes"
618 cmds = MuttCommands(out_f=sys.stdout)
619 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
621 cmds.cmd(lines[4 * i + 0])
622 cmds.cmd(lines[4 * i + 1])
623 cmds.push(lines[4 * i + 2])
624 cmds.push(lines[4 * i + 3])
627 captured = capsys.readouterr()
628 lines_out = captured.out.splitlines()
629 assert lines[0] in lines_out[0]
630 assert lines[1] in lines_out[1]
631 assert lines[7] in lines_out[2]
632 assert lines[6] in lines_out[3]
633 assert lines[3] in lines_out[4]
634 assert lines[2] in lines_out[5]
635 assert lines[4] in lines_out[6]
636 assert lines[5] in lines_out[7]
639 def basic_mime_tree(self):
653 Part("text", "html", "part.html", desc="HTML"),
658 "text", "png", "logo.png", cid="logo.png", desc="Logo"
664 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
665 mimetree = MIMETreeDFWalker()
669 def visitor_fn(item, stack, debugprint):
670 items.append((item, len(stack)))
672 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
673 assert len(items) == 5
674 assert items[0][0].subtype == "plain"
675 assert items[0][1] == 2
676 assert items[1][0].subtype == "html"
677 assert items[1][1] == 2
678 assert items[2][0].subtype == "alternative"
679 assert items[2][1] == 1
680 assert items[3][0].subtype == "png"
681 assert items[3][1] == 1
682 assert items[4][0].subtype == "relative"
683 assert items[4][1] == 0
685 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
686 mimetree = MIMETreeDFWalker()
689 def visitor_fn(item, stack, debugprint):
692 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
693 assert items[-1].subtype == "mixed"
695 def test_MIMETreeDFWalker_visitor_in_constructor(
696 self, basic_mime_tree
700 def visitor_fn(item, stack, debugprint):
703 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
704 mimetree.walk(basic_mime_tree)
705 assert len(items) == 5
707 def test_do_setup_no_extensions(self, const1, capsys):
708 "Assert basics about the setup command output"
709 do_setup(temppath=const1, out_f=sys.stdout)
710 captout = capsys.readouterr()
711 lines = captout.out.splitlines()
712 assert lines[2].endswith(f'{const1}"')
713 assert lines[4].endswith(const1)
714 assert "first-entry" in lines[-1]
715 assert "edit-file" in lines[-1]
717 def test_do_setup_extensions(self, const1, const2, capsys):
718 "Assert that extensions are passed to editor"
720 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
722 captout = capsys.readouterr()
723 lines = captout.out.splitlines()
724 # assert comma-separated list of extensions passed
725 assert lines[2].endswith(f'{const2},{const1}"')
726 assert lines[4].endswith(const1)
729 def string_io(self, const1, text=None):
730 return StringIO(text or const1)
732 def test_do_massage_basic(self, const1, string_io, capsys):
733 def converter(drafttext, draftpath, extensions, tempdir):
734 return Part("text", "plain", draftpath, orig=True)
743 captured = capsys.readouterr()
744 lines = captured.out.splitlines()
745 assert '="$my_editor"' in lines.pop(0)
746 assert '="$my_edit_headers"' in lines.pop(0)
747 assert "unset my_editor" == lines.pop(0)
748 assert "unset my_edit_headers" == lines.pop(0)
749 assert "send-message" in lines.pop(0)
750 assert "update-encoding" in lines.pop(0)
751 assert "source 'rm -f " in lines.pop(0)
752 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
754 def test_do_massage_fulltree(
755 self, string_io, const1, basic_mime_tree, capsys
757 def converter(drafttext, draftpath, extensions, tempdir):
758 return basic_mime_tree
767 captured = capsys.readouterr()
768 lines = captured.out.splitlines()[4:]
769 assert "send-message" in lines.pop(0)
770 assert "Related" in lines.pop(0)
771 assert "group-related" in lines.pop(0)
772 assert "tag-entry" in lines.pop(0)
773 assert "Logo" in lines.pop(0)
774 assert "content-id" in lines.pop(0)
775 assert "toggle-unlink" in lines.pop(0)
776 assert "logo.png" in lines.pop(0)
777 assert "tag-entry" in lines.pop(0)
778 assert "Alternative" in lines.pop(0)
779 assert "group-alternatives" in lines.pop(0)
780 assert "tag-entry" in lines.pop(0)
781 assert "HTML" in lines.pop(0)
782 assert "toggle-unlink" in lines.pop(0)
783 assert "part.html" in lines.pop(0)
784 assert "tag-entry" in lines.pop(0)
785 assert "Plain" in lines.pop(0)
786 assert "update-encoding" in lines.pop(0)
787 assert len(lines) == 2
790 def fake_filewriter(self):
795 def __call__(self, path, content, mode="w", **kwargs):
796 self._writes.append((path, content))
798 def pop(self, index=-1):
799 return self._writes.pop(index)
804 def markdown_non_converter(self, const1, const2):
805 return lambda s, text: f"{const1}{text}{const2}"
807 def test_converter_tree_basic(
808 self, const1, const2, fake_filewriter, markdown_non_converter
810 path = pathlib.Path(const2)
811 tree = convert_markdown_to_html(
812 const1, path, filewriter_fn=fake_filewriter
815 assert tree.subtype == "alternative"
816 assert len(tree.children) == 2
817 assert tree.children[0].subtype == "plain"
818 assert tree.children[0].path == path
819 assert tree.children[0].orig
820 assert tree.children[1].subtype == "html"
821 assert tree.children[1].path == path.with_suffix(".html")
823 def test_converter_writes(
829 markdown_non_converter,
831 path = pathlib.Path(const2)
833 with monkeypatch.context() as m:
834 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
835 convert_markdown_to_html(
836 const1, path, filewriter_fn=fake_filewriter
839 assert (path, const1) == fake_filewriter.pop(0)
841 path.with_suffix(".html"),
842 markdown_non_converter(None, const1),
843 ) == fake_filewriter.pop(0)
845 def test_markdown_inline_image_processor(self):
846 imgpath1 = "file:/path/to/image.png"
847 imgpath2 = "file:///path/to/image.png?url=params"
848 imgpath3 = "/path/to/image.png"
849 text = f"""![inline local image]({imgpath1})
851 with newline]({imgpath2})
852 ![image local path]({imgpath3})"""
853 text, html, images = markdown_with_inline_image_support(text)
855 # local paths have been normalised to URLs:
856 imgpath3 = f"file://{imgpath3}"
858 assert 'src="cid:' in html
859 assert "](cid:" in text
860 assert len(images) == 3
861 assert imgpath1 in images
862 assert imgpath2 in images
863 assert imgpath3 in images
864 assert images[imgpath1].cid != images[imgpath2].cid
865 assert images[imgpath1].cid != images[imgpath3].cid
866 assert images[imgpath2].cid != images[imgpath3].cid
868 def test_markdown_inline_image_processor_title_to_desc(self, const1):
869 imgpath = "file:///path/to/image.png"
870 text = f'![inline local image]({imgpath} "{const1}")'
871 text, html, images = markdown_with_inline_image_support(text)
872 assert images[imgpath].desc == const1
874 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
875 imgpath = "file:///path/to/image.png"
876 text = f"![{const1}]({imgpath})"
877 text, html, images = markdown_with_inline_image_support(text)
878 assert images[imgpath].desc == const1
880 def test_markdown_inline_image_processor_title_over_alt_desc(
883 imgpath = "file:///path/to/image.png"
884 text = f'![{const1}]({imgpath} "{const2}")'
885 text, html, images = markdown_with_inline_image_support(text)
886 assert images[imgpath].desc == const2
888 def test_markdown_inline_image_not_external(self):
889 imgpath = "https://path/to/image.png"
890 text = f"![inline image]({imgpath})"
891 text, html, images = markdown_with_inline_image_support(text)
893 assert 'src="cid:' not in html
894 assert "](cid:" not in text
895 assert len(images) == 0
897 def test_markdown_inline_image_local_file(self):
898 imgpath = "/path/to/image.png"
899 text = f"![inline image]({imgpath})"
900 text, html, images = markdown_with_inline_image_support(text)
902 for k, v in images.items():
903 assert k == f"file://{imgpath}"
909 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
910 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
913 def test_markdown_inline_image_processor_base64(self, test_png):
914 text = f"![1px white inlined]({test_png})"
915 text, html, images = markdown_with_inline_image_support(text)
917 assert 'src="cid:' in html
918 assert "](cid:" in text
919 assert len(images) == 1
920 assert test_png in images
922 def test_converter_tree_inline_image_base64(
923 self, test_png, const1, fake_filewriter
925 text = f"![inline base64 image]({test_png})"
926 path = pathlib.Path(const1)
927 tree = convert_markdown_to_html(
928 text, path, filewriter_fn=fake_filewriter
931 assert tree.subtype == "relative"
932 assert tree.children[1].subtype == "png"
933 written = fake_filewriter.pop()
934 assert tree.children[1].path == written[0]
935 assert written[1] == request.urlopen(test_png).read()
937 def test_inline_image_collection(
938 self, test_png, const1, const2, fake_filewriter
940 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
941 relparts = collect_inline_images(
942 test_images, filewriter_fn=fake_filewriter
945 written = fake_filewriter.pop()
946 assert b"PNG" in written[1]
948 assert relparts[0].subtype == "png"
949 assert relparts[0].path == written[0]
950 assert relparts[0].cid == const1
951 assert relparts[0].desc.endswith(const2)