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

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