]> git.madduck.net Git - etc/neomutt.git/blob - .config/neomutt/buildmimetree.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

0b62aa4f25b14dda3316db4c4a58c3dcf6794fdb
[etc/neomutt.git] / .config / neomutt / buildmimetree.py
1 #!/usr/bin/python3
2 #
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.
6 #
7 # Configuration:
8 #   neomuttrc (needs to be a single line):
9 #     set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
10 #     macro compose B "\
11 #       <enter-command> source '$my_confdir/buildmimetree.py setup \
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"
15 #
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
19 #     information)
20 #
21 # Requirements:
22 #   - python3
23 #   - python3-markdown
24 # Optional:
25 #   - pytest
26 #   - Pynliner
27 #   - Pygments, if installed, then syntax highlighting is enabled
28 #
29 # Latest version:
30 #   https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
31 #
32 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
33 # Released under the GPL-2+ licence, just like Mutt itself.
34 #
35
36 import sys
37 import pathlib
38 import markdown
39 import tempfile
40 import argparse
41 import re
42 import mimetypes
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
48
49
50 def parse_cli_args(*args, **kwargs):
51     parser = argparse.ArgumentParser(
52         description=(
53             "NeoMutt helper to turn text/markdown email parts "
54             "into full-fledged MIME trees"
55         )
56     )
57     parser.epilog = (
58         "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
59         "Released under the MIT licence"
60     )
61
62     subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
63     parser_setup = subp.add_parser("setup", help="Setup phase")
64     parser_massage = subp.add_parser("massage", help="Massaging phase")
65
66     parser_setup.add_argument(
67         "--extensions",
68         type=str,
69         default="",
70         help="Markdown extension to use (comma-separated list)"
71     )
72
73     parser_setup.add_argument(
74         "--only-build",
75         action="store_true",
76         help="Only build, don't send the message",
77     )
78
79     parser_setup.add_argument(
80         "--tempdir",
81         default=None,
82         help="Specify temporary directory to use for attachments",
83     )
84
85     parser_setup.add_argument(
86         "--debug-commands",
87         action="store_true",
88         help="Turn on debug logging of commands generated to stderr",
89     )
90
91     parser_massage.add_argument(
92         "--write-commands-to",
93         metavar="PATH",
94         dest="cmdpath",
95         help="Temporary file path to write commands to",
96     )
97
98     parser_massage.add_argument(
99         "--extensions",
100         metavar="EXTENSIONS",
101         type=str,
102         default="",
103         help="Markdown extension to use (comma-separated list)",
104     )
105
106     parser_massage.add_argument(
107         "--only-build",
108         action="store_true",
109         help="Only build, don't send the message",
110     )
111
112     parser_massage.add_argument(
113         "--tempdir",
114         default=None,
115         help="Specify temporary directory to use for attachments",
116     )
117
118     parser_massage.add_argument(
119         "--debug-commands",
120         action="store_true",
121         help="Turn on debug logging of commands generated to stderr",
122     )
123
124     parser_massage.add_argument(
125         "--debug-walk",
126         action="store_true",
127         help="Turn on debugging to stderr of the MIME tree walk",
128     )
129
130     parser_massage.add_argument(
131         "MAILDRAFT",
132         nargs="?",
133         help="If provided, the script is invoked as editor on the mail draft",
134     )
135
136     return parser.parse_args(*args, **kwargs)
137
138
139 # [ MARKDOWN WRAPPING ] #######################################################
140
141
142 InlineImageInfo = namedtuple(
143     "InlineImageInfo", ["cid", "desc"], defaults=[None]
144 )
145
146
147 class InlineImageExtension(Extension):
148     class RelatedImageInlineProcessor(ImageInlineProcessor):
149         def __init__(self, re, md, ext):
150             super().__init__(re, md)
151             self._ext = ext
152
153         def handleMatch(self, m, data):
154             el, start, end = super().handleMatch(m, data)
155             if "src" in el.attrib:
156                 src = el.attrib["src"]
157                 if "://" not in src or src.startswith("file://"):
158                     # We only inline local content
159                     cid = self._ext.get_cid_for_image(el.attrib)
160                     el.attrib["src"] = f"cid:{cid}"
161             return el, start, end
162
163     def __init__(self):
164         super().__init__()
165         self._images = OrderedDict()
166
167     def extendMarkdown(self, md):
168         md.registerExtension(self)
169         inline_image_proc = self.RelatedImageInlineProcessor(
170             IMAGE_LINK_RE, md, self
171         )
172         md.inlinePatterns.register(inline_image_proc, "image_link", 150)
173
174     def get_cid_for_image(self, attrib):
175         msgid = make_msgid()[1:-1]
176         path = attrib["src"]
177         if path.startswith("/"):
178             path = f"file://{path}"
179         self._images[path] = InlineImageInfo(
180             msgid, attrib.get("title", attrib.get("alt"))
181         )
182         return msgid
183
184     def get_images(self):
185         return self._images
186
187
188 def markdown_with_inline_image_support(text, *, extensions=None):
189     inline_image_handler = InlineImageExtension()
190     extensions = extensions or []
191     extensions.append(inline_image_handler)
192     mdwn = markdown.Markdown(extensions=extensions)
193     htmltext = mdwn.convert(text)
194
195     images = inline_image_handler.get_images()
196
197     def replace_image_with_cid(matchobj):
198         for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
199             if m in images:
200                 return f"(cid:{images[m].cid}"
201         return matchobj.group(0)
202
203     text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
204     return text, htmltext, images
205
206
207 # [ PARTS GENERATION ] ########################################################
208
209
210 class Part(
211     namedtuple(
212         "Part",
213         ["type", "subtype", "path", "desc", "cid", "orig"],
214         defaults=[None, None, False],
215     )
216 ):
217     def __str__(self):
218         ret = f"<{self.type}/{self.subtype}>"
219         if self.cid:
220             ret = f"{ret} cid:{self.cid}"
221         if self.orig:
222             ret = f"{ret} ORIGINAL"
223         return ret
224
225
226 class Multipart(
227     namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
228 ):
229     def __str__(self):
230         return f"<multipart/{self.subtype}> children={len(self.children)}"
231
232
233 def filewriter_fn(path, content, mode="w", **kwargs):
234     with open(path, mode, **kwargs) as out_f:
235         out_f.write(content)
236
237
238 def collect_inline_images(
239     images, *, tempdir=None, filewriter_fn=filewriter_fn
240 ):
241     relparts = []
242     for path, info in images.items():
243         data = request.urlopen(path)
244
245         mimetype = data.headers["Content-Type"]
246         ext = mimetypes.guess_extension(mimetype)
247         tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
248         path = pathlib.Path(tempfilename[1])
249
250         filewriter_fn(path, data.read(), "w+b")
251
252         relparts.append(
253             Part(*mimetype.split("/"), path, cid=info.cid, desc=f"Image: {info.desc}")
254         )
255
256     return relparts
257
258
259 def convert_markdown_to_html(
260     origtext,
261     draftpath,
262     *,
263     filewriter_fn=filewriter_fn,
264     tempdir=None,
265     extensions=None,
266 ):
267     origtext, htmltext, images = markdown_with_inline_image_support(
268         origtext, extensions=extensions
269     )
270
271     filewriter_fn(draftpath, origtext, encoding="utf-8")
272     textpart = Part(
273         "text", "plain", draftpath, "Plain-text version", orig=True
274     )
275
276     htmlpath = draftpath.with_suffix(".html")
277     filewriter_fn(
278         htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
279     )
280     htmlpart = Part("text", "html", htmlpath, "HTML version")
281
282     altpart = Multipart(
283         "alternative", [textpart, htmlpart], "Group of alternative content"
284     )
285
286     imgparts = collect_inline_images(
287         images, tempdir=tempdir, filewriter_fn=filewriter_fn
288     )
289     if imgparts:
290         return Multipart(
291             "relative", [altpart] + imgparts, "Group of related content"
292         )
293     else:
294         return altpart
295
296
297 class MIMETreeDFWalker:
298     def __init__(self, *, visitor_fn=None, debug=False):
299         self._visitor_fn = visitor_fn
300         self._debug = debug
301
302     def walk(self, root, *, visitor_fn=None):
303         """
304         Recursive function to implement a depth-dirst walk of the MIME-tree
305         rooted at `root`.
306         """
307
308         if isinstance(root, list):
309             root = Multipart("mixed", children=root)
310
311         self._walk(
312             root,
313             stack=[],
314             visitor_fn=visitor_fn or self._visitor_fn,
315         )
316
317     def _walk(self, node, *, stack, visitor_fn):
318         # Let's start by enumerating the parts at the current level. At the
319         # root level, stack will be the empty list, and we expect a multipart/*
320         # container at this level. Later, e.g. within a mutlipart/alternative
321         # container, the subtree will just be the alternative parts, while the
322         # top of the stack will be the multipart/alternative container, which
323         # we will process after the following loop.
324
325         lead = f"{'| '*len(stack)}|-"
326         if isinstance(node, Multipart):
327             self.debugprint(
328                 f"{lead}{node} parents={[s.subtype for s in stack]}"
329             )
330
331             # Depth-first, so push the current container onto the stack,
332             # then descend …
333             stack.append(node)
334             self.debugprint("| " * (len(stack) + 1))
335             for child in node.children:
336                 self._walk(
337                     child,
338                     stack=stack,
339                     visitor_fn=visitor_fn,
340                 )
341             self.debugprint("| " * len(stack))
342             assert stack.pop() == node
343
344         else:
345             self.debugprint(f"{lead}{node}")
346
347         if visitor_fn:
348             visitor_fn(node, stack, debugprint=self.debugprint)
349
350     def debugprint(self, s, **kwargs):
351         if self._debug:
352             print(s, file=sys.stderr, **kwargs)
353
354
355 # [ RUN MODES ] ###############################################################
356
357
358 class MuttCommands:
359     """
360     Stupid class to interface writing out Mutt commands. This is quite a hack
361     to deal with the fact that Mutt runs "push" commands in reverse order, so
362     all of a sudden, things become very complicated when mixing with "real"
363     commands.
364
365     Hence we keep two sets of commands, and one set of pushes. Commands are
366     added to the first until a push is added, after which commands are added to
367     the second set of commands.
368
369     On flush(), the first set is printed, followed by the pushes in reverse,
370     and then the second set is printed. All 3 sets are then cleared.
371     """
372
373     def __init__(self, out_f=sys.stdout, *, debug=False):
374         self._cmd1, self._push, self._cmd2 = [], [], []
375         self._out_f = out_f
376         self._debug = debug
377
378     def cmd(self, s):
379         self.debugprint(s)
380         if self._push:
381             self._cmd2.append(s)
382         else:
383             self._cmd1.append(s)
384
385     def push(self, s):
386         s = s.replace('"', '"')
387         s = f'push "{s}"'
388         self.debugprint(s)
389         self._push.insert(0, s)
390
391     def flush(self):
392         print(
393             "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
394         )
395         self._cmd1, self._push, self._cmd2 = [], [], []
396
397     def debugprint(self, s, **kwargs):
398         if self._debug:
399             print(s, file=sys.stderr, **kwargs)
400
401
402 def do_setup(
403     extensions=None,
404     *,
405     out_f=sys.stdout,
406     only_build=False,
407     temppath=None,
408     tempdir=None,
409     debug_commands=False,
410 ):
411     temppath = temppath or pathlib.Path(
412         tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
413     )
414     cmds = MuttCommands(out_f, debug=debug_commands)
415
416     editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
417     if extensions:
418         editor = f'{editor} --extensions {extensions}'
419     if only_build:
420         editor = f'{editor} --only-build'
421     if tempdir:
422         editor = f"{editor} --tempdir {tempdir}"
423     if debug_commands:
424         editor = f"{editor} --debug-commands"
425
426     cmds.cmd('set my_editor="$editor"')
427     cmds.cmd('set my_edit_headers="$edit_headers"')
428     cmds.cmd(f'set editor="{editor}"')
429     cmds.cmd("unset edit_headers")
430     cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
431     cmds.push("<first-entry><edit-file>")
432     cmds.flush()
433
434
435 def do_massage(
436     draft_f,
437     draftpath,
438     cmd_f,
439     *,
440     extensions=None,
441     converter=convert_markdown_to_html,
442     only_build=False,
443     tempdir=None,
444     debug_commands=False,
445     debug_walk=False,
446 ):
447     # Here's the big picture: we're being invoked as the editor on the email
448     # draft, and whatever commands we write to the file given as cmdpath will
449     # be run by the second source command in the macro definition.
450
451     # Let's start by cleaning up what the setup did (see above), i.e. we
452     # restore the $editor and $edit_headers variables, and also unset the
453     # variable used to identify the command file we're currently writing
454     # to.
455     cmds = MuttCommands(cmd_f, debug=debug_commands)
456     cmds.cmd('set editor="$my_editor"')
457     cmds.cmd('set edit_headers="$my_edit_headers"')
458     cmds.cmd("unset my_editor")
459     cmds.cmd("unset my_edit_headers")
460
461     # let's flush those commands, as there'll be a lot of pushes from now
462     # on, which need to be run in reverse order
463     cmds.flush()
464
465     extensions = extensions.split(",") if extensions else []
466     tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
467
468     mimetree = MIMETreeDFWalker(debug=debug_walk)
469
470     def visitor_fn(item, stack, *, debugprint=None):
471         """
472         Visitor function called for every node (part) of the MIME tree,
473         depth-first, and responsible for telling NeoMutt how to assemble
474         the tree.
475         """
476         KILL_LINE=r'\Ca\Ck'
477
478         if isinstance(item, Part):
479             # We've hit a leaf-node, i.e. an alternative or a related part
480             # with actual content.
481
482             # Let's add the part
483             if item.orig:
484                 # The original source already exists in the NeoMutt tree, but
485                 # the underlying file may have been modified, so we need to
486                 # update the encoding, but that's it:
487                 cmds.push("<update-encoding>")
488             else:
489                 # … whereas all other parts need to be added, and they're all
490                 # considered to be temporary and inline:
491                 cmds.push(f"<attach-file>{item.path}<enter>")
492                 cmds.push("<toggle-unlink><toggle-disposition>")
493
494             # If the item (including the original) comes with additional
495             # information, then we might just as well update the NeoMutt
496             # tree now:
497             if item.cid:
498                 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
499
500         elif isinstance(item, Multipart):
501             # This node has children, but we already visited them (see
502             # above), and so they have been tagged in NeoMutt's compose
503             # window. Now it's just a matter of telling NeoMutt to do the
504             # appropriate grouping:
505             if item.subtype == "alternative":
506                 cmds.push("<group-alternatives>")
507             elif item.subtype in ("relative", "related"):
508                 cmds.push("<group-related>")
509             elif item.subtype == "multilingual":
510                 cmds.push("<group-multilingual>")
511
512         else:
513             # We should never get here
514             assert not "is valid part"
515
516         # If the item has a description, we might just as well add it
517         if item.desc:
518             cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
519
520         # Finally, if we're at non-root level, tag the new container,
521         # as it might itself be part of a container, to be processed
522         # one level up:
523         if stack:
524             cmds.push("<tag-entry>")
525
526     # -----------------
527     # End of visitor_fn
528
529     # Let's walk the tree and visit every node with our fancy visitor
530     # function
531     mimetree.walk(tree, visitor_fn=visitor_fn)
532
533     if not only_build:
534         cmds.push("<send-message>")
535
536     # Finally, cleanup. Since we're responsible for removing the temporary
537     # file, how's this for a little hack?
538     try:
539         filename = cmd_f.name
540     except AttributeError:
541         filename = "pytest_internal_file"
542     cmds.cmd(f"source 'rm -f {filename}|'")
543     cmds.cmd("unset my_mdwn_postprocess_cmd_file")
544     cmds.flush()
545
546
547 # [ CLI ENTRY ] ###############################################################
548
549 if __name__ == "__main__":
550     args = parse_cli_args()
551
552     if args.mode == "setup":
553         do_setup(
554             args.extensions,
555             only_build=args.only_build,
556             tempdir=args.tempdir,
557             debug_commands=args.debug_commands,
558         )
559
560     elif args.mode == "massage":
561         with open(args.MAILDRAFT, "r") as draft_f, open(
562             args.cmdpath, "w"
563         ) as cmd_f:
564             do_massage(
565                 draft_f,
566                 pathlib.Path(args.MAILDRAFT),
567                 cmd_f,
568                 extensions=args.extensions,
569                 only_build=args.only_build,
570                 tempdir=args.tempdir,
571                 debug_commands=args.debug_commands,
572                 debug_walk=args.debug_walk,
573             )
574
575
576 # [ TESTS ] ###################################################################
577
578 try:
579     import pytest
580     from io import StringIO
581
582     class Tests:
583         @pytest.fixture
584         def const1(self):
585             return "CONSTANT STRING 1"
586
587         @pytest.fixture
588         def const2(self):
589             return "CONSTANT STRING 2"
590
591         # NOTE: tests using the capsys fixture must specify sys.stdout to the
592         # functions they call, else old stdout is used and not captured
593
594         def test_MuttCommands_cmd(self, const1, const2, capsys):
595             "Assert order of commands"
596             cmds = MuttCommands(out_f=sys.stdout)
597             cmds.cmd(const1)
598             cmds.cmd(const2)
599             cmds.flush()
600             captured = capsys.readouterr()
601             assert captured.out == "\n".join((const1, const2, ""))
602
603         def test_MuttCommands_push(self, const1, const2, capsys):
604             "Assert reverse order of pushes"
605             cmds = MuttCommands(out_f=sys.stdout)
606             cmds.push(const1)
607             cmds.push(const2)
608             cmds.flush()
609             captured = capsys.readouterr()
610             assert (
611                 captured.out
612                 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
613             )
614
615         def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
616             "Assert reverse order of pushes"
617             cmds = MuttCommands(out_f=sys.stdout)
618             lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
619             for i in range(2):
620                 cmds.cmd(lines[4 * i + 0])
621                 cmds.cmd(lines[4 * i + 1])
622                 cmds.push(lines[4 * i + 2])
623                 cmds.push(lines[4 * i + 3])
624             cmds.flush()
625
626             captured = capsys.readouterr()
627             lines_out = captured.out.splitlines()
628             assert lines[0] in lines_out[0]
629             assert lines[1] in lines_out[1]
630             assert lines[7] in lines_out[2]
631             assert lines[6] in lines_out[3]
632             assert lines[3] in lines_out[4]
633             assert lines[2] in lines_out[5]
634             assert lines[4] in lines_out[6]
635             assert lines[5] in lines_out[7]
636
637         @pytest.fixture
638         def basic_mime_tree(self):
639             return Multipart(
640                 "relative",
641                 children=[
642                     Multipart(
643                         "alternative",
644                         children=[
645                             Part(
646                                 "text",
647                                 "plain",
648                                 "part.txt",
649                                 desc="Plain",
650                                 orig=True,
651                             ),
652                             Part("text", "html", "part.html", desc="HTML"),
653                         ],
654                         desc="Alternative",
655                     ),
656                     Part(
657                         "text", "png", "logo.png", cid="logo.png", desc="Logo"
658                     ),
659                 ],
660                 desc="Related",
661             )
662
663         def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
664             mimetree = MIMETreeDFWalker()
665
666             items = []
667
668             def visitor_fn(item, stack, debugprint):
669                 items.append((item, len(stack)))
670
671             mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
672             assert len(items) == 5
673             assert items[0][0].subtype == "plain"
674             assert items[0][1] == 2
675             assert items[1][0].subtype == "html"
676             assert items[1][1] == 2
677             assert items[2][0].subtype == "alternative"
678             assert items[2][1] == 1
679             assert items[3][0].subtype == "png"
680             assert items[3][1] == 1
681             assert items[4][0].subtype == "relative"
682             assert items[4][1] == 0
683
684         def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
685             mimetree = MIMETreeDFWalker()
686             items = []
687
688             def visitor_fn(item, stack, debugprint):
689                 items.append(item)
690
691             mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
692             assert items[-1].subtype == "mixed"
693
694         def test_MIMETreeDFWalker_visitor_in_constructor(
695             self, basic_mime_tree
696         ):
697             items = []
698
699             def visitor_fn(item, stack, debugprint):
700                 items.append(item)
701
702             mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
703             mimetree.walk(basic_mime_tree)
704             assert len(items) == 5
705
706         def test_do_setup_no_extensions(self, const1, capsys):
707             "Assert basics about the setup command output"
708             do_setup(temppath=const1, out_f=sys.stdout)
709             captout = capsys.readouterr()
710             lines = captout.out.splitlines()
711             assert lines[2].endswith(f'{const1}"')
712             assert lines[4].endswith(const1)
713             assert "first-entry" in lines[-1]
714             assert "edit-file" in lines[-1]
715
716         def test_do_setup_extensions(self, const1, const2, capsys):
717             "Assert that extensions are passed to editor"
718             do_setup(
719                 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
720             )
721             captout = capsys.readouterr()
722             lines = captout.out.splitlines()
723             # assert comma-separated list of extensions passed
724             assert lines[2].endswith(f'{const2},{const1}"')
725             assert lines[4].endswith(const1)
726
727         @pytest.fixture
728         def string_io(self, const1, text=None):
729             return StringIO(text or const1)
730
731         def test_do_massage_basic(self, const1, string_io, capsys):
732             def converter(drafttext, draftpath, extensions, tempdir):
733                 return Part("text", "plain", draftpath, orig=True)
734
735             do_massage(
736                 draft_f=string_io,
737                 draftpath=const1,
738                 cmd_f=sys.stdout,
739                 converter=converter,
740             )
741
742             captured = capsys.readouterr()
743             lines = captured.out.splitlines()
744             assert '="$my_editor"' in lines.pop(0)
745             assert '="$my_edit_headers"' in lines.pop(0)
746             assert "unset my_editor" == lines.pop(0)
747             assert "unset my_edit_headers" == lines.pop(0)
748             assert "send-message" in lines.pop(0)
749             assert "update-encoding" in lines.pop(0)
750             assert "source 'rm -f " in lines.pop(0)
751             assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
752
753         def test_do_massage_fulltree(
754             self, string_io, const1, basic_mime_tree, capsys
755         ):
756             def converter(drafttext, draftpath, extensions, tempdir):
757                 return basic_mime_tree
758
759             do_massage(
760                 draft_f=string_io,
761                 draftpath=const1,
762                 cmd_f=sys.stdout,
763                 converter=converter,
764             )
765
766             captured = capsys.readouterr()
767             lines = captured.out.splitlines()[4:]
768             assert "send-message" in lines.pop(0)
769             assert "Related" in lines.pop(0)
770             assert "group-related" in lines.pop(0)
771             assert "tag-entry" in lines.pop(0)
772             assert "Logo" in lines.pop(0)
773             assert "content-id" in lines.pop(0)
774             assert "toggle-unlink" in lines.pop(0)
775             assert "logo.png" in lines.pop(0)
776             assert "tag-entry" in lines.pop(0)
777             assert "Alternative" in lines.pop(0)
778             assert "group-alternatives" in lines.pop(0)
779             assert "tag-entry" in lines.pop(0)
780             assert "HTML" in lines.pop(0)
781             assert "toggle-unlink" in lines.pop(0)
782             assert "part.html" in lines.pop(0)
783             assert "tag-entry" in lines.pop(0)
784             assert "Plain" in lines.pop(0)
785             assert "update-encoding" in lines.pop(0)
786             assert len(lines) == 2
787
788         @pytest.fixture
789         def fake_filewriter(self):
790             class FileWriter:
791                 def __init__(self):
792                     self._writes = []
793
794                 def __call__(self, path, content, mode="w", **kwargs):
795                     self._writes.append((path, content))
796
797                 def pop(self, index=-1):
798                     return self._writes.pop(index)
799
800             return FileWriter()
801
802         @pytest.fixture
803         def markdown_non_converter(self, const1, const2):
804             return lambda s, text: f"{const1}{text}{const2}"
805
806         def test_converter_tree_basic(
807             self, const1, const2, fake_filewriter, markdown_non_converter
808         ):
809             path = pathlib.Path(const2)
810             tree = convert_markdown_to_html(
811                 const1, path, filewriter_fn=fake_filewriter
812             )
813
814             assert tree.subtype == "alternative"
815             assert len(tree.children) == 2
816             assert tree.children[0].subtype == "plain"
817             assert tree.children[0].path == path
818             assert tree.children[0].orig
819             assert tree.children[1].subtype == "html"
820             assert tree.children[1].path == path.with_suffix(".html")
821
822         def test_converter_writes(
823             self,
824             const1,
825             const2,
826             fake_filewriter,
827             monkeypatch,
828             markdown_non_converter,
829         ):
830             path = pathlib.Path(const2)
831
832             with monkeypatch.context() as m:
833                 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
834                 convert_markdown_to_html(
835                     const1, path, filewriter_fn=fake_filewriter
836                 )
837
838             assert (path, const1) == fake_filewriter.pop(0)
839             assert (
840                 path.with_suffix(".html"),
841                 markdown_non_converter(None, const1),
842             ) == fake_filewriter.pop(0)
843
844         def test_markdown_inline_image_processor(self):
845             imgpath1 = "file:/path/to/image.png"
846             imgpath2 = "file:///path/to/image.png?url=params"
847             imgpath3 = "/path/to/image.png"
848             text = f"""![inline local image]({imgpath1})
849                        ![image inlined
850                          with newline]({imgpath2})
851                        ![image local path]({imgpath3})"""
852             text, html, images = markdown_with_inline_image_support(text)
853
854             # local paths have been normalised to URLs:
855             imgpath3 = f"file://{imgpath3}"
856
857             assert 'src="cid:' in html
858             assert "](cid:" in text
859             assert len(images) == 3
860             assert imgpath1 in images
861             assert imgpath2 in images
862             assert imgpath3 in images
863             assert images[imgpath1].cid != images[imgpath2].cid
864             assert images[imgpath1].cid != images[imgpath3].cid
865             assert images[imgpath2].cid != images[imgpath3].cid
866
867         def test_markdown_inline_image_processor_title_to_desc(self, const1):
868             imgpath = "file:///path/to/image.png"
869             text = f'![inline local image]({imgpath} "{const1}")'
870             text, html, images = markdown_with_inline_image_support(text)
871             assert images[imgpath].desc == const1
872
873         def test_markdown_inline_image_processor_alt_to_desc(self, const1):
874             imgpath = "file:///path/to/image.png"
875             text = f"![{const1}]({imgpath})"
876             text, html, images = markdown_with_inline_image_support(text)
877             assert images[imgpath].desc == const1
878
879         def test_markdown_inline_image_processor_title_over_alt_desc(
880             self, const1, const2
881         ):
882             imgpath = "file:///path/to/image.png"
883             text = f'![{const1}]({imgpath} "{const2}")'
884             text, html, images = markdown_with_inline_image_support(text)
885             assert images[imgpath].desc == const2
886
887         def test_markdown_inline_image_not_external(self):
888             imgpath = "https://path/to/image.png"
889             text = f"![inline image]({imgpath})"
890             text, html, images = markdown_with_inline_image_support(text)
891
892             assert 'src="cid:' not in html
893             assert "](cid:" not in text
894             assert len(images) == 0
895
896         def test_markdown_inline_image_local_file(self):
897             imgpath = "/path/to/image.png"
898             text = f"![inline image]({imgpath})"
899             text, html, images = markdown_with_inline_image_support(text)
900
901             for k, v in images.items():
902                 assert k == f"file://{imgpath}"
903                 break
904
905         @pytest.fixture
906         def test_png(self):
907             return (
908                 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
909                 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
910             )
911
912         def test_markdown_inline_image_processor_base64(self, test_png):
913             text = f"![1px white inlined]({test_png})"
914             text, html, images = markdown_with_inline_image_support(text)
915
916             assert 'src="cid:' in html
917             assert "](cid:" in text
918             assert len(images) == 1
919             assert test_png in images
920
921         def test_converter_tree_inline_image_base64(
922             self, test_png, const1, fake_filewriter
923         ):
924             text = f"![inline base64 image]({test_png})"
925             path = pathlib.Path(const1)
926             tree = convert_markdown_to_html(
927                 text, path, filewriter_fn=fake_filewriter
928             )
929
930             assert tree.subtype == "relative"
931             assert tree.children[1].subtype == "png"
932             written = fake_filewriter.pop()
933             assert tree.children[1].path == written[0]
934             assert written[1] == request.urlopen(test_png).read()
935
936         def test_inline_image_collection(
937             self, test_png, const1, const2, fake_filewriter
938         ):
939             test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
940             relparts = collect_inline_images(
941                 test_images, filewriter_fn=fake_filewriter
942             )
943
944             written = fake_filewriter.pop()
945             assert b"PNG" in written[1]
946
947             assert relparts[0].subtype == "png"
948             assert relparts[0].path == written[0]
949             assert relparts[0].cid == const1
950             assert relparts[0].desc.endswith(const2)
951
952 except ImportError:
953     pass