All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
3 # NeoMutt helper script to create multipart/* emails with Markdown → HTML
4 # alternative conversion, and handling of inline images, using NeoMutt's
5 # ability to manually craft MIME trees, but automating this process.
8 # neomuttrc (needs to be a single line):
9 # set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
11 # <enter-command> source '$my_confdir/buildmimetree.py \
12 # --tempdir $tempdir --extensions $my_mdwn_extensions \
13 # --css-file $my_confdir/htmlmail.css |'<enter>\
14 # <enter-command> source \$my_mdwn_postprocess_cmd_file<enter>\
15 # " "Convert message into a modern MIME tree with inline images"
17 # (Yes, we need to call source twice, as mutt only starts to process output
18 # from a source command when the command exits, and since we need to react
19 # to the output, we need to be invoked again, using a $my_ variable to pass
25 # - python3-beautifulsoup4
28 # - Pynliner, provides --css-file and thus inline styling of HTML output
29 # - Pygments, then syntax highlighting for fenced code is enabled
32 # pytest -x buildmimetree.py
35 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
37 # Copyright © 2023–24 martin f. krafft <madduck@madduck.net>
38 # Released under the GPL-2+ licence, just like NeoMutt itself.
50 import xml.etree.ElementTree as etree
54 from contextlib import contextmanager
55 from collections import namedtuple, OrderedDict
56 from markdown.extensions import Extension
57 from markdown.blockprocessors import BlockProcessor
58 from markdown.inlinepatterns import (
59 SimpleTextInlineProcessor,
63 from email.utils import make_msgid
64 from urllib import request
67 def parse_cli_args(*args, **kwargs):
68 parser = argparse.ArgumentParser(
70 "NeoMutt helper to turn text/markdown email parts "
71 "into full-fledged MIME trees"
75 "Copyright © 2023-24 martin f. krafft <madduck@madduck.net>.\n"
76 "Released under the MIT licence"
81 metavar="EXT[,EXT[,EXT]]",
84 help="Markdown extension to use (comma-separated list)",
93 help="CSS file to merge with the final HTML",
96 parser.set_defaults(css_file=None)
99 "--related-to-html-only",
101 help="Make related content be sibling to HTML parts only",
104 def positive_integer(value):
112 raise ValueError("Must be a positive integer")
115 "--max-number-other-attachments",
117 type=positive_integer,
119 help="Maximum number of other attachments to expect",
126 help="Only build, don't send the message",
131 help="Domain to use in content IDs",
138 help="Specify temporary directory to use for attachments",
144 help="Turn on debug logging of commands generated to stderr",
150 help="Turn on debugging to stderr of the MIME tree walk",
157 help="Write the generated HTML to the file",
160 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
161 massage_p = subp.add_parser(
162 "massage", help="Massaging phase (internal use)"
165 massage_p.add_argument(
166 "--write-commands-to",
172 help="Temporary file path to write commands to",
175 massage_p.add_argument(
179 help="If provided, the script is invoked as editor on the mail draft",
182 return parser.parse_args(*args, **kwargs)
185 # [ FILE I/O HANDLING ] #######################################################
193 def __init__(self, path=None, mode="r", content=None, **kwargs):
196 raise RuntimeError("Cannot specify path and content for File")
199 path if isinstance(path, pathlib.Path) else pathlib.Path(path)
204 if content and not re.search(r"[r+]", mode):
205 raise RuntimeError("Cannot specify content without read mode")
207 self._cache = {File.Op.R: [content] if content else [], File.Op.W: []}
210 self._kwargs = kwargs
215 self._file = open(self._path, self._mode, **self._kwargs)
216 elif "b" in self._mode:
217 self._file = io.BytesIO()
219 self._file = io.StringIO()
225 def __exit__(self, exc_type, exc_val, exc_tb):
231 self._cache[File.Op.R] = self._cache[File.Op.W]
234 def _get_cache(self, op):
235 return (b"" if "b" in self._mode else "").join(self._cache[op])
237 def _add_to_cache(self, op, s):
238 self._cache[op].append(s)
240 def read(self, *, cache=True):
241 if cache and self._cache[File.Op.R]:
242 return self._get_cache(File.Op.R)
244 if self._lastop == File.Op.W:
247 except io.UnsupportedOperation:
250 self._lastop = File.Op.R
253 self._add_to_cache(File.Op.R, self._file.read())
254 return self._get_cache(File.Op.R)
256 return self._file.read()
258 def write(self, s, *, cache=True):
259 if self._lastop == File.Op.R:
262 except io.UnsupportedOperation:
266 self._add_to_cache(File.Op.W, s)
268 self._cache[File.Op.R] = self._cache[File.Op.W]
270 written = self._file.write(s)
272 self._lastop = File.Op.W
275 path = property(lambda s: s._path)
279 f'<File path={self._path or "(buffered)"} open={bool(self._file)} '
280 f"rcache={sum(len(c) for c in self._rcache) if self._rcache is not None else False} "
281 f"wcache={sum(len(c) for c in self._wcache) if self._wcache is not None else False}>"
289 def __call__(self, path=None, mode="r", content=None, **kwargs):
290 f = File(path, mode, content, **kwargs)
291 self._files.append(f)
295 return self._files.__len__()
297 def pop(self, idx=-1):
298 return self._files.pop(idx)
300 def __getitem__(self, idx):
301 return self._files.__getitem__(idx)
303 def __contains__(self, f):
304 return self._files.__contains__(f)
307 class FakeFileFactory(FileFactory):
310 self._paths2files = OrderedDict()
312 def __call__(self, path=None, mode="r", content=None, **kwargs):
313 if path in self._paths2files:
314 return self._paths2files[path]
316 f = super().__call__(None, mode, content, **kwargs)
317 self._paths2files[path] = f
321 class FakeFile(File):
324 # this is quality Python! We do this so that the fake file, which has
325 # no path, fake-pretends to have a path for testing purposes.
327 f.__class__ = FakeFile
330 def __getitem__(self, path):
331 return self._paths2files.__getitem__(path)
333 def get(self, path, default):
334 return self._paths2files.get(path, default)
336 def pop(self, last=True):
337 return self._paths2files.popitem(last)
341 f"<FakeFileFactory nfiles={len(self._files)} "
342 f"paths={len(self._paths2files)}>"
346 # [ IMAGE HANDLING ] ##########################################################
349 InlineImageInfo = namedtuple(
350 "InlineImageInfo", ["cid", "desc"], defaults=[None]
356 self._images = OrderedDict()
358 def register(self, path, description=None, *, domain=None):
359 # path = str(pathlib.Path(path).expanduser())
360 path = os.path.expanduser(path)
361 if path.startswith("/"):
362 path = f"file://{path}"
363 cid = make_msgid(domain=domain)[1:-1]
364 self._images[path] = InlineImageInfo(cid, description)
368 return self._images.__iter__()
370 def __getitem__(self, idx):
371 return self._images.__getitem__(idx)
374 return self._images.__len__()
377 return self._images.items()
380 return f"<ImageRegistry(items={len(self._images)})>"
383 return self._images.__str__()
386 class InlineImageExtension(Extension):
387 class RelatedImageInlineProcessor(ImageInlineProcessor):
388 def __init__(self, re, md, registry):
389 super().__init__(re, md)
390 self._registry = registry
392 def handleMatch(self, m, data):
393 el, start, end = super().handleMatch(m, data)
394 if "src" in el.attrib:
395 src = el.attrib["src"]
396 if "://" not in src or src.startswith("file://"):
397 # We only inline local content
398 cid = self._registry.register(
400 el.attrib.get("title", el.attrib.get("alt")),
402 el.attrib["src"] = f"cid:{cid}"
403 return el, start, end
405 def __init__(self, registry):
407 self._image_registry = registry
409 INLINE_PATTERN_NAME = "image_link"
411 def extendMarkdown(self, md):
412 md.registerExtension(self)
413 inline_image_proc = self.RelatedImageInlineProcessor(
414 IMAGE_LINK_RE, md, self._image_registry
416 md.inlinePatterns.register(
417 inline_image_proc, InlineImageExtension.INLINE_PATTERN_NAME, 150
421 def markdown_with_inline_image_support(
427 extension_configs=None,
430 image_registry if image_registry is not None else ImageRegistry()
432 inline_image_handler = InlineImageExtension(registry=registry)
433 extensions = extensions or []
434 extensions.append(inline_image_handler)
435 mdwn = markdown.Markdown(
436 extensions=extensions, extension_configs=extension_configs
439 htmltext = mdwn.convert(text)
441 def replace_image_with_cid(matchobj):
442 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
444 return f"(cid:{registry[m].cid}"
445 return matchobj.group(0)
447 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
448 return text, htmltext, registry, mdwn
451 # [ CSS STYLING ] #############################################################
455 with warnings.catch_warnings():
456 # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1081037
457 warnings.filterwarnings("ignore", category=SyntaxWarning)
466 from pygments.formatters import get_formatter_by_name
468 _CODEHILITE_CLASS = "codehilite"
470 _PYGMENTS_CSS = get_formatter_by_name(
471 "html", style="default"
472 ).get_style_defs(f".{_CODEHILITE_CLASS}")
478 def apply_styling(html, css):
482 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
487 # [ FORMAT=FLOWED HANDLING ] ##################################################
490 class FormatFlowedNewlineExtension(Extension):
491 FFNL_RE = r"(?!\S)(\s)\n"
493 def extendMarkdown(self, md):
494 ffnl = SimpleTextInlineProcessor(self.FFNL_RE)
495 md.inlinePatterns.register(ffnl, "ffnl", 125)
498 # [ QUOTE HANDLING ] ##########################################################
501 class QuoteToAdmonitionExtension(Extension):
502 class BlockProcessor(BlockProcessor):
503 RE = re.compile(r"(?:^|\n)>\s*(.*)")
505 def __init__(self, parser):
506 super().__init__(parser)
508 self._disable = False
510 def test(self, parent, blocks):
514 if markdown.util.nearing_recursion_limit():
517 lines = blocks.splitlines()
522 elif not self.RE.search(lines[0]):
525 return len(lines) > 0
527 elif not self.RE.search(lines[0]) and self.RE.search(lines[1]):
530 elif self._title and self.RE.search(lines[1]):
535 def run(self, parent, blocks):
536 quotelines = blocks.pop(0).splitlines()
538 cont = bool(self._title)
539 if not self.RE.search(quotelines[0]):
540 self._title = quotelines.pop(0)
542 admonition = etree.SubElement(parent, "div")
544 "class", f"admonition quote{' continued' if cont else ''}"
546 self.parser.parseChunk(admonition, self._title)
548 admonition[0].set("class", "admonition-title")
550 self.parser.parseChunk(admonition, "\n".join(quotelines))
556 self._disable = False
559 def clean(klass, line):
560 m = klass.RE.match(line)
561 return m.group(1) if m else line
563 def extendMarkdown(self, md):
564 md.registerExtension(self)
565 email_quote_proc = self.BlockProcessor(md.parser)
566 md.parser.blockprocessors.register(email_quote_proc, "emailquote", 25)
569 # [ PARTS GENERATION ] ########################################################
575 ["type", "subtype", "path", "desc", "cid", "orig"],
576 defaults=[None, None, False],
580 ret = f"<{self.type}/{self.subtype}>"
582 ret = f"{ret} cid:{self.cid}"
584 ret = f"{ret} ORIGINAL"
589 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
592 return f"<multipart/{self.subtype}> children={len(self.children)}"
595 return hash(str(self.subtype) + "".join(str(self.children)))
598 def collect_inline_images(
599 image_registry, *, tempdir=None, filefactory=FileFactory()
602 for path, info in image_registry.items():
603 if path.startswith("cid:"):
606 data = request.urlopen(path)
608 mimetype = data.headers["Content-Type"]
609 ext = mimetypes.guess_extension(mimetype)
610 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
611 path = pathlib.Path(tempfilename[1])
613 with filefactory(path, "w+b") as out_f:
614 out_f.write(data.read())
616 # filewriter_fn(path, data.read(), "w+b")
619 f'Inline image: "{info.desc}"'
621 else f"Inline image {str(len(relparts)+1)}"
624 Part(*mimetype.split("/"), path, cid=info.cid, desc=desc)
630 EMAIL_SIG_SEP = "\n-- \n"
631 HTML_SIG_MARKER = "=htmlsig "
634 def make_html_doc(body, sig=None):
639 '<meta http-equiv="content-type" content="text/html; charset=UTF-8">\n' # noqa: E501
640 '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' # noqa: E501
649 f'{ret}<div id="signature"><span class="sig_separator">{EMAIL_SIG_SEP.strip(nl)}</span>\n' # noqa: E501
654 return f"{ret}\n </body>\n</html>"
657 def make_text_mail(text, sig=None):
658 return EMAIL_SIG_SEP.join((text, sig)) if sig else text
661 def extract_signature(text, *, filefactory=FileFactory()):
662 parts = text.split(EMAIL_SIG_SEP, 1)
664 return text, None, None
666 lines = parts[1].splitlines()
667 if lines[0].startswith(HTML_SIG_MARKER):
668 path = pathlib.Path(re.split(r" +", lines.pop(0), maxsplit=1)[1])
669 textsig = "\n".join(lines)
671 with filefactory(path.expanduser()) as sig_f:
672 sig_input = sig_f.read()
674 soup = bs4.BeautifulSoup(sig_input, "html.parser")
676 style = str(soup.style.extract()) if soup.style else ""
677 for sig_selector in (
687 sig = soup.select_one(sig_selector)
692 return parts[0], textsig, style + sig_input
694 if sig.attrs.get("id") == "signature":
695 sig = "".join(str(c) for c in sig.children)
697 return parts[0], textsig, style + str(sig)
699 return parts[0], parts[1], None
702 def convert_markdown_to_html(
705 related_to_html_only=False,
708 filefactory=FileFactory(),
711 extension_configs=None,
714 # TODO extension_configs need to be handled differently
715 extension_configs = extension_configs or {}
716 extension_configs.setdefault("pymdownx.highlight", {})[
718 ] = _CODEHILITE_CLASS
720 extensions = extensions or []
721 extensions.append(FormatFlowedNewlineExtension())
722 extensions.append(QuoteToAdmonitionExtension())
724 draft = draft_f.read()
725 origtext, textsig, htmlsig = extract_signature(
726 draft, filefactory=filefactory
734 ) = markdown_with_inline_image_support(
735 origtext, extensions=extensions, extension_configs=extension_configs
740 # TODO: decide what to do if there is no plain-text version
741 raise NotImplementedError("HTML signature but no text alternative")
743 soup = bs4.BeautifulSoup(htmlsig, "html.parser")
744 for img in soup.find_all("img"):
745 uri = img.attrs["src"]
746 desc = img.attrs.get("title", img.attrs.get("alt"))
747 cid = image_registry.register(uri, desc, domain=domain)
748 img.attrs["src"] = f"cid:{cid}"
758 ) = markdown_with_inline_image_support(
760 extensions=extensions,
761 extension_configs=extension_configs,
762 image_registry=image_registry,
766 origtext = make_text_mail(origtext, textsig)
767 draft_f.write(origtext)
769 "text", "plain", draft_f.path, "Plain-text version", orig=True
772 htmltext = make_html_doc(htmltext, htmlsig)
773 htmltext = apply_styling(htmltext, css_f.read() if css_f else None)
776 htmlpath = draft_f.path.with_suffix(".html")
778 htmlpath = pathlib.Path(
779 tempfile.mkstemp(suffix=".html", dir=tempdir)[1]
782 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
784 out_f.write(htmltext)
785 htmlpart = Part("text", "html", htmlpath, "HTML version")
788 htmldump_f.write(htmltext)
790 imgparts = collect_inline_images(
791 image_registry, tempdir=tempdir, filefactory=filefactory
794 if related_to_html_only:
795 # If there are inline image part, they will be contained within a
796 # multipart/related part along with the HTML part only
798 # replace htmlpart with a multipart/related container of the HTML
799 # parts and the images
800 htmlpart = Multipart(
801 "relative", [htmlpart] + imgparts, "Group of related content"
805 "alternative", [textpart, htmlpart], "Group of alternative content"
809 # If there are inline image part, they will be siblings to the
810 # multipart/alternative tree within a multipart/related part
812 "alternative", [textpart, htmlpart], "Group of alternative content"
816 "relative", [altpart] + imgparts, "Group of related content"
822 class MIMETreeDFWalker:
823 def __init__(self, *, visitor_fn=None, debug=False):
824 self._visitor_fn = visitor_fn or self._echovisit
827 def _echovisit(self, node, ancestry, debugprint):
828 debugprint(f"node={node} ancestry={ancestry}")
830 def walk(self, root, *, visitor_fn=None):
832 Recursive function to implement a depth-dirst walk of the MIME-tree
835 if isinstance(root, list):
837 root = Multipart("mixed", children=root)
845 visitor_fn=visitor_fn or self._visitor_fn,
848 def _walk(self, node, *, ancestry, descendents, visitor_fn):
849 # Let's start by enumerating the parts at the current level. At the
850 # root level, ancestry will be the empty list, and we expect a
851 # multipart/* container at this level. Later, e.g. within a
852 # mutlipart/alternative container, the subtree will just be the
853 # alternative parts, while the top of the ancestry will be the
854 # multipart/alternative container, which we will process after the
857 lead = f"{'│ '*len(ancestry)}"
858 if isinstance(node, Multipart):
860 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
863 # Depth-first, so push the current container onto the ancestry
864 # stack, then descend …
865 ancestry.append(node)
866 self.debugprint(lead + "│ " * 2)
867 for child in node.children:
871 descendents=descendents,
872 visitor_fn=visitor_fn,
874 assert ancestry.pop() == node
875 sibling_descendents = descendents
876 descendents.extend(node.children)
879 self.debugprint(f"{lead}├{node}")
880 sibling_descendents = descendents
882 if False and ancestry:
883 self.debugprint(lead[:-1] + " │")
887 node, ancestry, sibling_descendents, debugprint=self.debugprint
890 def debugprint(self, s, **kwargs):
892 print(s, file=sys.stderr, **kwargs)
895 # [ RUN MODES ] ###############################################################
900 Stupid class to interface writing out Mutt commands. This is quite a hack
901 to deal with the fact that Mutt runs "push" commands in reverse order, so
902 all of a sudden, things become very complicated when mixing with "real"
905 Hence we keep two sets of commands, and one set of pushes. Commands are
906 added to the first until a push is added, after which commands are added to
907 the second set of commands.
909 On flush(), the first set is printed, followed by the pushes in reverse,
910 and then the second set is printed. All 3 sets are then cleared.
913 def __init__(self, out_f=sys.stdout, *, debug=False):
914 self._cmd1, self._push, self._cmd2 = [], [], []
926 s = s.replace('"', r"\"")
929 self._push.insert(0, s)
933 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
935 self._cmd1, self._push, self._cmd2 = [], [], []
937 def debugprint(self, s, **kwargs):
939 print(s, file=sys.stderr, **kwargs)
947 debug_commands=False,
949 temppath = temppath or pathlib.Path(
950 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
952 cmds = MuttCommands(out_f, debug=debug_commands)
954 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
956 cmds.cmd('set my_editor="$editor"')
957 cmds.cmd('set my_edit_headers="$edit_headers"')
958 cmds.cmd(f'set editor="{editor}"')
959 cmds.cmd("unset edit_headers")
960 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
961 cmds.push("<first-entry><edit-file>")
972 converter=convert_markdown_to_html,
973 related_to_html_only=True,
975 max_other_attachments=20,
978 debug_commands=False,
981 # Here's the big picture: we're being invoked as the editor on the email
982 # draft, and whatever commands we write to the file given as cmdpath will
983 # be run by the second source command in the macro definition.
985 # Let's start by cleaning up what the setup did (see above), i.e. we
986 # restore the $editor and $edit_headers variables, and also unset the
987 # variable used to identify the command file we're currently writing
989 cmds = MuttCommands(cmd_f, debug=debug_commands)
991 extensions = extensions.split(",") if extensions else []
995 htmldump_f=htmldump_f,
996 related_to_html_only=related_to_html_only,
998 extensions=extensions,
1002 mimetree = MIMETreeDFWalker(debug=debug_walk)
1004 state = dict(pos=1, tags={}, parts=1)
1006 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
1008 Visitor function called for every node (part) of the MIME tree,
1009 depth-first, and responsible for telling NeoMutt how to assemble
1012 KILL_LINE = r"\Ca\Ck"
1014 if isinstance(item, Part):
1015 # We've hit a leaf-node, i.e. an alternative or a related part
1016 # with actual content.
1018 # Let's add the part
1020 # The original source already exists in the NeoMutt tree, but
1021 # the underlying file may have been modified, so we need to
1022 # update the encoding, but that's it:
1023 cmds.push("<first-entry>")
1024 cmds.push("<update-encoding>")
1026 # We really just need to be able to assume that at this point,
1027 # NeoMutt is at position 1, and that we've processed only this
1028 # part so far. Nevermind about actual attachments, we can
1029 # safely ignore those as they stay at the end.
1030 assert state["pos"] == 1
1031 assert state["parts"] == 1
1033 # … whereas all other parts need to be added, and they're all
1034 # considered to be temporary and inline:
1035 cmds.push(f"<attach-file>{item.path}<enter>")
1036 cmds.push("<toggle-unlink><toggle-disposition>")
1038 # This added a part at the end of the list of parts, and that's
1039 # just how many parts we've seen so far, so it's position in
1040 # the NeoMutt compose list is the count of parts
1042 state["pos"] = state["parts"]
1044 # If the item (including the original) comes with additional
1045 # information, then we might just as well update the NeoMutt
1048 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
1050 # Now for the biggest hack in this script, which is to handle
1051 # attachments, such as PDFs, that aren't related or alternatives.
1052 # The problem is that when we add an inline image, it always gets
1053 # appended to the list, i.e. inserted *after* other attachments.
1054 # Since we don't know the number of attachments, we also cannot
1055 # infer the postition of the new attachment. Therefore, we bubble
1056 # it all the way to the top, only to then move it down again:
1057 if state["pos"] > 1: # skip for the first part
1058 for i in range(max_other_attachments):
1059 # could use any number here, but has to be larger than the
1060 # number of possible attachments. The performance
1061 # difference of using a high number is negligible.
1062 # Bubble up the new part
1063 cmds.push("<move-up>")
1065 # As we push the part to the right position in the list (i.e.
1066 # the last of the subset of attachments this script added), we
1067 # must handle the situation that subtrees are skipped by
1068 # NeoMutt. Hence, the actual number of positions to move down
1069 # is decremented by the number of descendents so far
1071 for i in range(1, state["pos"] - len(descendents)):
1072 cmds.push("<move-down>")
1074 elif isinstance(item, Multipart):
1075 # This node has children, but we already visited them (see
1076 # above). The tags dictionary of State should contain a list of
1077 # their positions in the NeoMutt compose window, so iterate those
1078 # and tag the parts there:
1079 n_tags = len(state["tags"][item])
1080 for tag in state["tags"][item]:
1081 cmds.push(f"<jump>{tag}<enter><tag-entry>")
1083 if item.subtype == "alternative":
1084 cmds.push("<group-alternatives>")
1085 elif item.subtype in ("relative", "related"):
1086 cmds.push("<group-related>")
1087 elif item.subtype == "multilingual":
1088 cmds.push("<group-multilingual>")
1090 raise NotImplementedError(
1091 f"Handling of multipart/{item.subtype} is not implemented"
1094 state["pos"] -= n_tags - 1
1098 # We should never get here
1099 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
1101 # If the item has a description, we might just as well add it
1103 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
1106 # If there's an ancestry, record the current (assumed) position in
1107 # the NeoMutt compose window as needed-to-tag by our direct parent
1108 # (i.e. the last item of the ancestry)
1109 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
1111 lead = "│ " * (len(ancestry) + 1) + "* "
1113 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
1114 f"{lead}descendents={[d.subtype for d in descendents]}\n"
1115 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
1116 f"{lead}pos={state['pos']}, parts={state['parts']}"
1122 # Let's walk the tree and visit every node with our fancy visitor
1124 mimetree.walk(tree, visitor_fn=visitor_fn)
1127 cmds.push("<send-message>")
1129 # Finally, cleanup. Since we're responsible for removing the temporary
1130 # file, how's this for a little hack?
1132 filename = cmd_f.name
1133 except AttributeError:
1134 filename = "pytest_internal_file"
1135 cmds.cmd(f"source 'rm -f {filename}|'")
1136 cmds.cmd('set editor="$my_editor"')
1137 cmds.cmd('set edit_headers="$my_edit_headers"')
1138 cmds.cmd("unset my_editor")
1139 cmds.cmd("unset my_edit_headers")
1140 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
1144 # [ CLI ENTRY ] ###############################################################
1146 if __name__ == "__main__":
1147 args = parse_cli_args()
1149 if args.mode is None:
1151 tempdir=args.tempdir,
1152 debug_commands=args.debug_commands,
1155 elif args.mode == "massage":
1157 File(args.MAILDRAFT, "r+") as draft_f,
1158 File(args.cmdpath, "w") as cmd_f,
1159 File(args.css_file, "r") as css_f,
1160 File(args.dump_html, "w") as htmldump_f,
1165 extensions=args.extensions,
1167 htmldump_f=htmldump_f,
1168 related_to_html_only=args.related_to_html_only,
1169 max_other_attachments=args.max_number_other_attachments,
1170 only_build=args.only_build,
1171 tempdir=args.tempdir,
1173 debug_commands=args.debug_commands,
1174 debug_walk=args.debug_walk,
1178 # [ TESTS ] ###################################################################
1186 return "Curvature Vest Usher Dividing+T#iceps Senior"
1190 return "Habitant Celestial 2litzy Resurf/ce Headpiece Harmonics"
1194 return pathlib.Path("/does/not/exist")
1197 def fakepath2(self):
1198 return pathlib.Path("/does/not/exist/either")
1200 # NOTE: tests using the capsys fixture must specify sys.stdout to the
1201 # functions they call, else old stdout is used and not captured
1203 @pytest.mark.muttctrl
1204 def test_MuttCommands_cmd(self, const1, const2, capsys):
1205 "Assert order of commands"
1206 cmds = MuttCommands(out_f=sys.stdout)
1210 captured = capsys.readouterr()
1211 assert captured.out == "\n".join((const1, const2, ""))
1213 @pytest.mark.muttctrl
1214 def test_MuttCommands_push(self, const1, const2, capsys):
1215 "Assert reverse order of pushes"
1216 cmds = MuttCommands(out_f=sys.stdout)
1220 captured = capsys.readouterr()
1223 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
1226 @pytest.mark.muttctrl
1227 def test_MuttCommands_push_escape(self, const1, const2, capsys):
1228 cmds = MuttCommands(out_f=sys.stdout)
1229 cmds.push(f'"{const1}"')
1231 captured = capsys.readouterr()
1232 assert f'"\\"{const1}\\""' in captured.out
1234 @pytest.mark.muttctrl
1235 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
1236 "Assert reverse order of pushes"
1237 cmds = MuttCommands(out_f=sys.stdout)
1238 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
1240 cmds.cmd(lines[4 * i + 0])
1241 cmds.cmd(lines[4 * i + 1])
1242 cmds.push(lines[4 * i + 2])
1243 cmds.push(lines[4 * i + 3])
1246 captured = capsys.readouterr()
1247 lines_out = captured.out.splitlines()
1248 assert lines[0] in lines_out[0]
1249 assert lines[1] in lines_out[1]
1250 assert lines[7] in lines_out[2]
1251 assert lines[6] in lines_out[3]
1252 assert lines[3] in lines_out[4]
1253 assert lines[2] in lines_out[5]
1254 assert lines[4] in lines_out[6]
1255 assert lines[5] in lines_out[7]
1258 def mime_tree_related_to_alternative(self):
1272 Part("text", "html", "part.html", desc="HTML"),
1277 "text", "png", "logo.png", cid="logo.png", desc="Logo"
1284 def mime_tree_related_to_html(self):
1298 Part("text", "html", "part.html", desc="HTML"),
1314 def mime_tree_nested(self):
1335 desc="Nested plain",
1344 desc="Nested alternative",
1360 @pytest.mark.treewalk
1361 def test_MIMETreeDFWalker_depth_first_walk(
1362 self, mime_tree_related_to_alternative
1364 mimetree = MIMETreeDFWalker()
1368 def visitor_fn(item, ancestry, descendents, debugprint):
1369 items.append((item, len(ancestry), len(descendents)))
1372 mime_tree_related_to_alternative, visitor_fn=visitor_fn
1374 assert len(items) == 5
1375 assert items[0][0].subtype == "plain"
1376 assert items[0][1] == 2
1377 assert items[0][2] == 0
1378 assert items[1][0].subtype == "html"
1379 assert items[1][1] == 2
1380 assert items[1][2] == 0
1381 assert items[2][0].subtype == "alternative"
1382 assert items[2][1] == 1
1383 assert items[2][2] == 2
1384 assert items[3][0].subtype == "png"
1385 assert items[3][1] == 1
1386 assert items[3][2] == 2
1387 assert items[4][0].subtype == "relative"
1388 assert items[4][1] == 0
1389 assert items[4][2] == 4
1391 @pytest.mark.treewalk
1392 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
1393 mimetree = MIMETreeDFWalker()
1396 def visitor_fn(item, ancestry, descendents, debugprint):
1399 p = Part("text", "plain", const1)
1400 mimetree.walk([p], visitor_fn=visitor_fn)
1401 assert items[-1].subtype == "plain"
1402 mimetree.walk([p, p], visitor_fn=visitor_fn)
1403 assert items[-1].subtype == "mixed"
1405 @pytest.mark.treewalk
1406 def test_MIMETreeDFWalker_visitor_in_constructor(
1407 self, mime_tree_related_to_alternative
1411 def visitor_fn(item, ancestry, descendents, debugprint):
1414 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
1415 mimetree.walk(mime_tree_related_to_alternative)
1416 assert len(items) == 5
1419 def string_io(self, const1, text=None):
1420 return StringIO(text or const1)
1422 @pytest.mark.massage
1423 def test_do_massage_basic(self):
1424 def converter(draft_f, **kwargs):
1425 return Part("text", "plain", draft_f.path, orig=True)
1427 with File() as draft_f, File() as cmd_f:
1431 converter=converter,
1433 lines = cmd_f.read().splitlines()
1435 assert "send-message" in lines.pop(0)
1436 assert "update-encoding" in lines.pop(0)
1437 assert "first-entry" in lines.pop(0)
1438 assert "source 'rm -f " in lines.pop(0)
1439 assert '="$my_editor"' in lines.pop(0)
1440 assert '="$my_edit_headers"' in lines.pop(0)
1441 assert "unset my_editor" == lines.pop(0)
1442 assert "unset my_edit_headers" == lines.pop(0)
1443 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
1445 @pytest.mark.massage
1446 def test_do_massage_fulltree(self, mime_tree_related_to_alternative):
1447 def converter(draft_f, **kwargs):
1448 return mime_tree_related_to_alternative
1452 with File() as draft_f, File() as cmd_f:
1456 max_other_attachments=max_attachments,
1457 converter=converter,
1459 lines = cmd_f.read().splitlines()[:-6]
1461 assert "first-entry" in lines.pop()
1462 assert "update-encoding" in lines.pop()
1463 assert "Plain" in lines.pop()
1464 assert "part.html" in lines.pop()
1465 assert "toggle-unlink" in lines.pop()
1466 for i in range(max_attachments):
1467 assert "move-up" in lines.pop()
1468 assert "move-down" in lines.pop()
1469 assert "HTML" in lines.pop()
1470 assert "jump>1" in lines.pop()
1471 assert "jump>2" in lines.pop()
1472 assert "group-alternatives" in lines.pop()
1473 assert "Alternative" in lines.pop()
1474 assert "logo.png" in lines.pop()
1475 assert "toggle-unlink" in lines.pop()
1476 assert "content-id" in lines.pop()
1477 for i in range(max_attachments):
1478 assert "move-up" in lines.pop()
1479 assert "move-down" in lines.pop()
1480 assert "Logo" in lines.pop()
1481 assert "jump>1" in lines.pop()
1482 assert "jump>4" in lines.pop()
1483 assert "group-related" in lines.pop()
1484 assert "Related" in lines.pop()
1485 assert "send-message" in lines.pop()
1486 assert len(lines) == 0
1488 @pytest.mark.massage
1489 def test_mime_tree_relative_within_alternative(
1490 self, mime_tree_related_to_html
1492 def converter(draft_f, **kwargs):
1493 return mime_tree_related_to_html
1495 with File() as draft_f, File() as cmd_f:
1499 converter=converter,
1501 lines = cmd_f.read().splitlines()[:-6]
1503 assert "first-entry" in lines.pop()
1504 assert "update-encoding" in lines.pop()
1505 assert "Plain" in lines.pop()
1506 assert "part.html" in lines.pop()
1507 assert "toggle-unlink" in lines.pop()
1508 assert "move-up" in lines.pop()
1511 if "move-up" not in top:
1513 assert "move-down" in top
1514 assert "HTML" in lines.pop()
1515 assert "logo.png" in lines.pop()
1516 assert "toggle-unlink" in lines.pop()
1517 assert "content-id" in lines.pop()
1518 assert "move-up" in lines.pop()
1521 if "move-up" not in top:
1523 assert "move-down" in top
1524 assert "move-down" in lines.pop()
1525 assert "Logo" in lines.pop()
1526 assert "jump>2" in lines.pop()
1527 assert "jump>3" in lines.pop()
1528 assert "group-related" in lines.pop()
1529 assert "Related" in lines.pop()
1530 assert "jump>1" in lines.pop()
1531 assert "jump>2" in lines.pop()
1532 assert "group-alternative" in lines.pop()
1533 assert "Alternative" in lines.pop()
1534 assert "send-message" in lines.pop()
1535 assert len(lines) == 0
1537 @pytest.mark.massage
1538 def test_mime_tree_nested_trees_does_not_break_positioning(
1539 self, mime_tree_nested
1541 def converter(draft_f, **kwargs):
1542 return mime_tree_nested
1544 with File() as draft_f, File() as cmd_f:
1548 converter=converter,
1550 lines = cmd_f.read().splitlines()
1552 while "logo.png" not in lines.pop():
1555 assert "content-id" in lines.pop()
1556 assert "move-up" in lines.pop()
1559 if "move-up" not in top:
1561 assert "move-down" in top
1562 # Due to the nested trees, the number of descendents of the sibling
1563 # actually needs to be considered, not just the nieces. So to move
1564 # from position 1 to position 6, it only needs one <move-down>
1565 # because that jumps over the entire sibling tree. Thus what
1566 # follows next must not be another <move-down>
1567 assert "Logo" in lines.pop()
1569 @pytest.mark.converter
1570 def test_converter_tree_basic(self, fakepath, const1, fakefilefactory):
1571 with fakefilefactory(fakepath, content=const1) as draft_f:
1572 tree = convert_markdown_to_html(
1573 draft_f, filefactory=fakefilefactory
1576 assert tree.subtype == "alternative"
1577 assert len(tree.children) == 2
1578 assert tree.children[0].subtype == "plain"
1579 assert tree.children[0].path == draft_f.path
1580 assert tree.children[0].orig
1581 assert tree.children[1].subtype == "html"
1582 assert tree.children[1].path == fakepath.with_suffix(".html")
1584 @pytest.mark.converter
1585 def test_converter_writes(
1586 self, fakepath, fakefilefactory, const1, monkeypatch
1588 with fakefilefactory(fakepath, content=const1) as draft_f:
1589 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1591 html = fakefilefactory.pop()
1592 assert fakepath.with_suffix(".html") == html[0]
1593 assert const1 in html[1].read()
1594 text = fakefilefactory.pop()
1595 assert fakepath == text[0]
1596 assert const1 == text[1].read()
1598 @pytest.mark.imgproc
1599 def test_markdown_inline_image_processor(self):
1600 imgpath1 = "file:/path/to/image.png"
1601 imgpath2 = "file:///path/to/image.png?url=params"
1602 imgpath3 = "/path/to/image.png"
1603 text = f"""![inline local image]({imgpath1})
1605 with newline]({imgpath2})
1606 ![image local path]({imgpath3})"""
1607 text, html, images, mdwn = markdown_with_inline_image_support(text)
1609 # local paths have been normalised to URLs:
1610 imgpath3 = f"file://{imgpath3}"
1612 assert 'src="cid:' in html
1613 assert "](cid:" in text
1614 assert len(images) == 3
1615 assert imgpath1 in images
1616 assert imgpath2 in images
1617 assert imgpath3 in images
1618 assert images[imgpath1].cid != images[imgpath2].cid
1619 assert images[imgpath1].cid != images[imgpath3].cid
1620 assert images[imgpath2].cid != images[imgpath3].cid
1622 @pytest.mark.imgproc
1623 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1624 imgpath = "file:///path/to/image.png"
1625 text = f'![inline local image]({imgpath} "{const1}")'
1626 text, html, images, mdwn = markdown_with_inline_image_support(text)
1627 assert images[imgpath].desc == const1
1629 @pytest.mark.imgproc
1630 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1631 imgpath = "file:///path/to/image.png"
1632 text = f"![{const1}]({imgpath})"
1633 text, html, images, mdwn = markdown_with_inline_image_support(text)
1634 assert images[imgpath].desc == const1
1636 @pytest.mark.imgproc
1637 def test_markdown_inline_image_processor_title_over_alt_desc(
1638 self, const1, const2
1640 imgpath = "file:///path/to/image.png"
1641 text = f'![{const1}]({imgpath} "{const2}")'
1642 text, html, images, mdwn = markdown_with_inline_image_support(text)
1643 assert images[imgpath].desc == const2
1645 @pytest.mark.imgproc
1646 def test_markdown_inline_image_not_external(self):
1647 imgpath = "https://path/to/image.png"
1648 text = f"![inline image]({imgpath})"
1649 text, html, images, mdwn = markdown_with_inline_image_support(text)
1651 assert 'src="cid:' not in html
1652 assert "](cid:" not in text
1653 assert len(images) == 0
1655 @pytest.mark.imgproc
1656 def test_markdown_inline_image_local_file(self):
1657 imgpath = "/path/to/image.png"
1658 text = f"![inline image]({imgpath})"
1659 text, html, images, mdwn = markdown_with_inline_image_support(text)
1661 for k, v in images.items():
1662 assert k == f"file://{imgpath}"
1665 @pytest.mark.imgproc
1666 def test_markdown_inline_image_expanduser(self):
1667 imgpath = pathlib.Path("~/image.png")
1668 text = f"![inline image]({imgpath})"
1669 text, html, images, mdwn = markdown_with_inline_image_support(text)
1671 for k, v in images.items():
1672 assert k == f"file://{imgpath.expanduser()}"
1678 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1679 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1682 @pytest.mark.imgproc
1683 def test_markdown_inline_image_processor_base64(self, test_png):
1684 text = f"![1px white inlined]({test_png})"
1685 text, html, images, mdwn = markdown_with_inline_image_support(text)
1687 assert 'src="cid:' in html
1688 assert "](cid:" in text
1689 assert len(images) == 1
1690 assert test_png in images
1692 @pytest.mark.converter
1693 def test_converter_tree_inline_image_base64(
1694 self, test_png, fakefilefactory
1696 text = f"![inline base64 image]({test_png})"
1697 with fakefilefactory(content=text) as draft_f:
1698 tree = convert_markdown_to_html(
1700 filefactory=fakefilefactory,
1701 related_to_html_only=False,
1703 assert tree.subtype == "relative"
1704 assert tree.children[0].subtype == "alternative"
1705 assert tree.children[1].subtype == "png"
1706 written = fakefilefactory.pop()
1707 assert tree.children[1].path == written[0]
1708 assert b"PNG" in written[1].read()
1710 @pytest.mark.converter
1711 def test_converter_tree_inline_image_base64_related_to_html(
1712 self, test_png, fakefilefactory
1714 text = f"![inline base64 image]({test_png})"
1715 with fakefilefactory(content=text) as draft_f:
1716 tree = convert_markdown_to_html(
1718 filefactory=fakefilefactory,
1719 related_to_html_only=True,
1721 assert tree.subtype == "alternative"
1722 assert tree.children[1].subtype == "relative"
1723 assert tree.children[1].children[1].subtype == "png"
1724 written = fakefilefactory.pop()
1725 assert tree.children[1].children[1].path == written[0]
1726 assert b"PNG" in written[1].read()
1728 @pytest.mark.converter
1729 def test_converter_tree_inline_image_cid(
1730 self, const1, fakefilefactory
1732 text = f"![inline base64 image](cid:{const1})"
1733 with fakefilefactory(content=text) as draft_f:
1734 tree = convert_markdown_to_html(
1736 filefactory=fakefilefactory,
1737 related_to_html_only=False,
1739 assert len(tree.children) == 2
1740 assert tree.children[0].cid != const1
1741 assert tree.children[0].type != "image"
1742 assert tree.children[1].cid != const1
1743 assert tree.children[1].type != "image"
1746 def fakefilefactory(self):
1747 return FakeFileFactory()
1749 @pytest.mark.imgcoll
1750 def test_inline_image_collection(
1751 self, test_png, const1, const2, fakefilefactory
1753 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1754 relparts = collect_inline_images(
1755 test_images, filefactory=fakefilefactory
1758 written = fakefilefactory.pop()
1759 assert b"PNG" in written[1].read()
1761 assert relparts[0].subtype == "png"
1762 assert relparts[0].path == written[0]
1763 assert relparts[0].cid == const1
1764 assert const2 in relparts[0].desc
1768 @pytest.mark.styling
1769 def test_apply_stylesheet(self):
1770 html = "<p>Hello, world!</p>"
1771 css = "p { color:red }"
1772 out = apply_styling(html, css)
1773 assert 'p style="color' in out
1775 @pytest.mark.styling
1776 def test_apply_no_stylesheet(self, const1):
1777 out = apply_styling(const1, None)
1779 @pytest.mark.massage
1780 @pytest.mark.styling
1781 def test_massage_styling_to_converter(self):
1782 css = "p { color:red }"
1785 def converter(draft_f, css_f, **kwargs):
1787 css_applied.append(css)
1788 return Part("text", "plain", draft_f.path, orig=True)
1792 File(mode="w") as cmd_f,
1793 File(content=css) as css_f,
1799 converter=converter,
1801 assert css_applied[0] == css
1803 @pytest.mark.converter
1804 @pytest.mark.styling
1805 def test_converter_apply_styles(
1806 self, const1, monkeypatch, fakepath, fakefilefactory
1808 css = "p { color:red }"
1810 monkeypatch.context() as m,
1811 fakefilefactory(fakepath, content=const1) as draft_f,
1812 fakefilefactory(content=css) as css_f,
1817 lambda s, t: f"<p>{t}</p>",
1819 convert_markdown_to_html(
1820 draft_f, css_f=css_f, filefactory=fakefilefactory
1824 fakefilefactory[fakepath.with_suffix(".html")].read(),
1829 @pytest.mark.styling
1830 def test_apply_stylesheet_pygments(self):
1832 f'<div class="{_CODEHILITE_CLASS}">'
1833 "<pre>def foo():\n return</pre></div>"
1835 out = apply_styling(html, _PYGMENTS_CSS)
1836 assert f'{_CODEHILITE_CLASS}" style="' in out
1839 def test_signature_extraction_no_signature(self, const1):
1840 assert (const1, None, None) == extract_signature(const1)
1843 def test_signature_extraction_just_text(self, const1, const2):
1844 origtext, textsig, htmlsig = extract_signature(
1845 f"{const1}{EMAIL_SIG_SEP}{const2}"
1847 assert origtext == const1
1848 assert textsig == const2
1849 assert htmlsig is None
1852 def test_signature_extraction_html(
1853 self, fakepath, fakefilefactory, const1, const2
1855 sigconst = "HTML signature from {path} but as a string"
1856 sig = f'<div id="signature">{sigconst.format(path=fakepath)}</div>'
1858 sig_f = fakefilefactory(fakepath, content=sig)
1860 origtext, textsig, htmlsig = extract_signature(
1861 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER} {fakepath}\n{const2}",
1862 filefactory=fakefilefactory,
1864 assert origtext == const1
1865 assert textsig == const2
1866 assert htmlsig == sigconst.format(path=fakepath)
1869 def test_signature_extraction_file_not_found(self, fakepath, const1):
1870 with pytest.raises(FileNotFoundError):
1871 origtext, textsig, htmlsig = extract_signature(
1872 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER}{fakepath}\n{const1}"
1875 @pytest.mark.imgproc
1876 def test_image_registry(self, const1):
1877 reg = ImageRegistry()
1878 cid = reg.register(const1)
1880 assert not cid.startswith("<")
1881 assert not cid.endswith(">")
1882 assert const1 in reg
1884 @pytest.mark.imgproc
1885 def test_image_registry_domain(self, const1, const2):
1886 reg = ImageRegistry()
1887 cid = reg.register(const1, domain=const2)
1888 assert f"@{const2}" in cid
1889 assert not cid.startswith("<")
1890 assert not cid.endswith(">")
1891 assert const1 in reg
1893 @pytest.mark.imgproc
1894 def test_image_registry_file_uri(self, const1):
1895 reg = ImageRegistry()
1896 reg.register("/some/path")
1898 assert path.startswith("file://")
1901 @pytest.mark.converter
1903 def test_converter_signature_handling(
1904 self, fakepath, fakefilefactory, monkeypatch
1907 "This is the mail body\n",
1909 "This is a plain-text signature only",
1914 fakepath, content="".join(mailparts)
1916 monkeypatch.context() as m,
1918 m.setattr(markdown.Markdown, "convert", lambda s, t: t)
1919 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1921 soup = bs4.BeautifulSoup(
1922 fakefilefactory[fakepath.with_suffix(".html")].read(),
1925 body = soup.body.contents
1927 assert mailparts[0] in body.pop(0)
1929 sig = soup.select_one("#signature")
1930 assert sig == body.pop(0)
1932 sep = sig.select_one("span.sig_separator")
1933 assert sep == sig.contents[0]
1934 assert f"\n{sep.text}\n" == EMAIL_SIG_SEP
1936 assert mailparts[2] in sig.contents[1]
1938 @pytest.mark.converter
1940 def test_converter_signature_handling_htmlsig(
1941 self, fakepath, fakepath2, fakefilefactory, monkeypatch
1944 "This is the mail body",
1946 f"{HTML_SIG_MARKER}{fakepath2}\n",
1947 "This is the plain-text version",
1949 htmlsig = "HTML Signature from {path} but as a string"
1950 html = f'<div id="signature"><p>{htmlsig.format(path=fakepath2)}</p></div>'
1952 sig_f = fakefilefactory(fakepath2, content=html)
1959 fakepath, content="".join(mailparts)
1961 monkeypatch.context() as m,
1964 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1966 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1968 soup = bs4.BeautifulSoup(
1969 fakefilefactory[fakepath.with_suffix(".html")].read(),
1972 sig = soup.select_one("#signature")
1975 assert HTML_SIG_MARKER not in sig.text
1976 assert htmlsig.format(path=fakepath2) == sig.text.strip()
1978 plaintext = fakefilefactory[fakepath].read()
1979 assert plaintext.endswith(EMAIL_SIG_SEP + mailparts[-1])
1981 @pytest.mark.converter
1983 def test_converter_signature_handling_htmlsig_with_image(
1984 self, fakepath, fakepath2, fakefilefactory, monkeypatch, test_png
1987 "This is the mail body",
1989 f"{HTML_SIG_MARKER}{fakepath2}\n",
1990 "This is the plain-text version",
1993 "HTML Signature from {path} with image\n"
1994 f'<img src="{test_png}">\n'
1997 f'<div id="signature">{htmlsig.format(path=fakepath2)}</div>'
2000 sig_f = fakefilefactory(fakepath2, content=html)
2007 fakepath, content="".join(mailparts)
2009 monkeypatch.context() as m,
2012 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
2014 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2016 assert fakefilefactory.pop()[0].suffix == ".png"
2018 soup = bs4.BeautifulSoup(
2019 fakefilefactory[fakepath.with_suffix(".html")].read(),
2022 assert soup.img.attrs["src"].startswith("cid:")
2024 @pytest.mark.converter
2026 def test_converter_signature_handling_textsig_with_image(
2027 self, fakepath, fakefilefactory, test_png
2030 "This is the mail body",
2032 "This is the plain-text version with image\n",
2033 f"![Inline]({test_png})",
2037 fakepath, content="".join(mailparts)
2040 tree = convert_markdown_to_html(
2041 draft_f, filefactory=fakefilefactory
2044 assert tree.subtype == "relative"
2045 assert tree.children[0].subtype == "alternative"
2046 assert tree.children[1].subtype == "png"
2047 written = fakefilefactory.pop()
2048 assert tree.children[1].path == written[0]
2049 assert written[1].read() == request.urlopen(test_png).read()
2051 @pytest.mark.converter
2052 def test_converter_attribution_to_admonition(
2053 self, fakepath, fakefilefactory
2056 "Regarding whatever",
2057 "> blockquote line1",
2058 "> blockquote line2",
2060 "> new para with **bold** text",
2062 with fakefilefactory(
2063 fakepath, content="\n".join(mailparts)
2065 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2067 soup = bs4.BeautifulSoup(
2068 fakefilefactory[fakepath.with_suffix(".html")].read(),
2071 quote = soup.select_one("div.admonition.quote")
2074 soup.select_one("p.admonition-title").extract().text.strip()
2078 p = quote.p.extract()
2079 assert p.text.strip() == "\n".join(p[2:] for p in mailparts[1:3])
2081 p = quote.p.extract()
2082 assert p.contents[1].name == "strong"
2084 @pytest.mark.converter
2085 def test_converter_attribution_to_admonition_with_blockquote(
2086 self, fakepath, fakefilefactory
2089 "Regarding whatever",
2090 "> blockquote line1",
2091 "> blockquote line2",
2093 "> new para with **bold** text",
2095 with fakefilefactory(
2096 fakepath, content="\n".join(mailparts)
2098 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2100 soup = bs4.BeautifulSoup(
2101 fakefilefactory[fakepath.with_suffix(".html")].read(),
2104 quote = soup.select_one("div.admonition.quote")
2105 assert quote.blockquote
2107 @pytest.mark.converter
2108 def test_converter_attribution_to_admonition_multiple(
2109 self, fakepath, fakefilefactory
2112 "Regarding whatever",
2113 "> blockquote line1",
2114 "> blockquote line2",
2118 "> continued emailquote",
2120 "Another email-quote",
2123 with fakefilefactory(
2124 fakepath, content="\n".join(mailparts)
2126 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2128 soup = bs4.BeautifulSoup(
2129 fakefilefactory[fakepath.with_suffix(".html")].read(),
2132 quote = soup.select_one("div.admonition.quote.continued").extract()
2135 quote.select_one("p.admonition-title").extract().text.strip()
2139 p = quote.p.extract()
2142 quote = soup.select_one("div.admonition.quote.continued").extract()
2145 quote.select_one("p.admonition-title").extract().text.strip()
2149 @pytest.mark.converter
2150 def test_converter_format_flowed_with_nl2br(
2151 self, fakepath, fakefilefactory
2154 "This is format=flowed text ",
2155 "with spaces at the end ",
2156 "and there ought be no newlines.",
2158 "[link](https://example.org) ",
2162 "broken up](https://example.org).",
2164 "This is on a new line with a hard break ",
2165 "due to the double space",
2167 with fakefilefactory(
2168 fakepath, content="\n".join(mailparts)
2170 convert_markdown_to_html(
2171 draft_f, extensions=["nl2br"], filefactory=fakefilefactory
2174 soup = bs4.BeautifulSoup(
2175 fakefilefactory[fakepath.with_suffix(".html")].read(),
2180 p = soup.p.extract().text
2181 assert "".join(mailparts[0:3]) == p
2182 p = ''.join(map(str, soup.p.extract().contents))
2183 assert p == '<a href="https://example.org">link</a> and text.'
2184 p = ''.join(map(str, soup.p.extract().contents))
2186 p == '<a href="https://example.org">link text broken up</a>.'
2190 def test_file_class_contextmanager(self, const1, monkeypatch):
2191 state = dict(o=False, c=False)
2196 with monkeypatch.context() as m:
2197 m.setattr(File, "open", lambda s: fn("o"))
2198 m.setattr(File, "close", lambda s: fn("c"))
2201 assert not state["c"]
2205 def test_file_class_no_path(self, const1):
2206 with File(mode="w+") as f:
2207 f.write(const1, cache=False)
2208 assert f.read(cache=False) == const1
2211 def test_file_class_path(self, const1, tmp_path):
2212 with File(tmp_path / "file", mode="w+") as f:
2213 f.write(const1, cache=False)
2214 assert f.read(cache=False) == const1
2217 def test_file_class_path_no_exists(self, fakepath):
2218 with pytest.raises(FileNotFoundError):
2219 File(fakepath, mode="r").open()
2222 def test_file_class_cache(self, tmp_path, const1, const2):
2223 path = tmp_path / "file"
2224 file = File(path, mode="w+")
2226 f.write(const1, cache=True)
2227 with open(path, mode="w") as f:
2230 assert f.read(cache=True) == const1
2233 def test_file_class_cache_init(self, const1):
2234 file = File(path=None, mode="r", content=const1)
2236 assert f.read() == const1
2239 def test_file_class_content_or_path(self, fakepath, const1):
2240 with pytest.raises(RuntimeError):
2241 file = File(path=fakepath, content=const1)
2244 def test_file_class_content_needs_read(self, const1):
2245 with pytest.raises(RuntimeError):
2246 file = File(mode="w", content=const1)
2249 def test_file_class_write_persists_close(self, const1):
2254 assert f.read() == const1
2257 def test_file_class_write_resets_read_cache(self, const1, const2):
2258 with File(mode="w+", content=const1) as f:
2259 assert f.read() == const1
2261 assert f.read() == const2
2264 def test_file_factory(self):
2265 fact = FileFactory()
2267 assert isinstance(f, File)
2268 assert len(fact) == 1
2273 def test_fake_file_factory(self, fakepath, fakefilefactory):
2274 fact = FakeFileFactory()
2275 f = fakefilefactory(fakepath)
2276 assert f.path == fakepath
2277 assert f == fakefilefactory[fakepath]
2280 def test_fake_file_factory_path_persistence(
2281 self, fakepath, fakefilefactory
2283 f1 = fakefilefactory(fakepath)
2284 assert f1 == fakefilefactory(fakepath)