]> 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:

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