From 918157cb41a57d1a20e597c4a3860ed5706bf189 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Fri, 9 Oct 2026 16:55:04 +0200 Subject: [PATCH] raw derive: a placeholder text/plain no longer displaces the HTML text of a mail (#204) A mail's text is its plain text unless that is under 10% of the HTML text, then the HTML text with a warning; one rule for .eml and .msg. The other version of the text in a multipart/alternative is no longer an attachment. Files changed: - CHANGES.md - VERSION - docs/reference/commands/raw-material-and-uploads.md - testfiles/mail/gog-roundcube.eml.expect - tools/CONTRACT.md - tools/chemenu/commands/raw_cmd.py - tools/chemenu/filters/mail.py - tools/chemenu/filters/msg.py - tools/chemenu/tests/test_raw_derive.py - tools/chemenu/tests/test_raw_derive_binary.py Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2 --- CHANGES.md | 3 +- VERSION | 2 +- .../commands/raw-material-and-uploads.md | 2 +- testfiles/mail/gog-roundcube.eml.expect | 3 + tools/CONTRACT.md | 2 +- tools/chemenu/commands/raw_cmd.py | 6 +- tools/chemenu/filters/mail.py | 66 ++++++++++++-- tools/chemenu/filters/msg.py | 17 ++-- tools/chemenu/tests/test_raw_derive.py | 87 ++++++++++++++++++- tools/chemenu/tests/test_raw_derive_binary.py | 26 ++++++ 10 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 testfiles/mail/gog-roundcube.eml.expect diff --git a/CHANGES.md b/CHANGES.md index 18c0c75..ed81bd7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model. --- -## 9.0.0-beta.19 - 2026-10-09 - testfiles: real documents get short names and a source table, personal data is replaced by same-length filler, and the derive test now compares the sheet TSVs (#203) +## 9.0.0-beta.20 - 2026-10-09 - raw derive (Mail): ein Platzhalter-Plaintext (unter 10 % des HTML-Texts) verdrängt den HTML-Text nicht mehr, und die zweite Fassung des Mailtexts wird kein Anhang (#204) **Author:** Torben Nehmer @@ -38,6 +38,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model. - raw derive reads PDF-free documents: Office files and mail become a folder with the original and a derived text - raw derive unpacks ZIP archives and mails with attachments into a folder - raw derive reads PDF and Outlook MSG through lazily imported packages; doctor shows each import filter +- raw derive (Mail): ein Platzhalter-Plaintext (unter 10 % des HTML-Texts) verdrängt den HTML-Text nicht mehr, und die zweite Fassung des Mailtexts wird kein Anhang (#204) **Low impact** - version bump: Abschlussmeldung nennt Stichpunkt oder Themenabsatz statt pauschal Prosa diff --git a/VERSION b/VERSION index 44f00a6..1c7bc44 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.0.0-beta.19 +9.0.0-beta.20 diff --git a/docs/reference/commands/raw-material-and-uploads.md b/docs/reference/commands/raw-material-and-uploads.md index 2b72a69..d231d93 100644 --- a/docs/reference/commands/raw-material-and-uploads.md +++ b/docs/reference/commands/raw-material-and-uploads.md @@ -118,7 +118,7 @@ tools/wikitool raw derive incoming/scans.zip - A single file directly in `incoming/` becomes a folder named after its stem (or `--name`) holding `original.` - the original bytes, moved - and `derived.md`. The fixed names keep the path inside the path budget; the header of `derived.md` records the file's own name as `original_name`. - A folder is derived in place: each file with a filter gets `.md` beside it, nothing is renamed, and the files without a filter are named in the output. - `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version. -- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a package in `tools/requirements.txt` that is imported only when such a file is derived. A missing package refuses that format alone, with the preflight hint; `wikitool doctor` shows one `import-filter-` line per package-backed format. A PDF gets `` markers and a WARN when it yields almost no text (a scan; there is no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an embedded message inside it is named, not read. +- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a package in `tools/requirements.txt` that is imported only when such a file is derived. A missing package refuses that format alone, with the preflight hint; `wikitool doctor` shows one `import-filter-` line per package-backed format. A PDF gets `` markers and a WARN when it yields almost no text (a scan; there is no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an embedded message inside it is named, not read. The text of a mail (`.eml` and `.msg` alike) is its plain text, unless that is under 10% of the length of the HTML text - a "plain text version not available" placeholder - in which case it is the HTML text and a WARN names both lengths. The other version of the text in a `multipart/alternative` is not an attachment. - A workbook (`.xlsx`, `.xlsm`) gets its cells as files: beside its `.md` a folder `.sheets/` (`derived.sheets/` for a single file) holds one `NN-.tsv` per sheet - tab-separated per RFC 4180, UTF-8, row 1 of the file is row 1 of the sheet - and, for a sheet with formulas, `NN-.formulas.tsv` listing cell, formula and last stored value. The `.md` is the overview: one section per sheet with its range, the file names and a preview. Formulas are not recalculated; a shared formula is written out per cell, and one that cannot be is named `shared:` with a WARN. The folder counts against the path budget and is accepted with the rest; an existing one is never overwritten. - A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder `incoming//` holding its files under their own names, each with a `.md` where a filter reads it; the ZIP is removed and the output names it with its sha256. A mail becomes a folder with `original.eml`, `derived.md` and its attachments under their own names; a ZIP attached to it is unpacked once into a folder of its own, and an attached mail is quoted in `derived.md`, not unpacked. A ZIP inside a ZIP, an absolute or `..` path, a symbolic link, an encrypted entry, more than 1000 entries, a file over 25 MiB or more than 250 MiB in total refuse the whole call. Hidden entries (`__MACOSX/`, `.DS_Store`, anything starting with a dot) are named, not written. A name taken twice, or reserved (`original.*` and `derived.md` at the top of a mail's folder, `_capture.json` anywhere), gets `-2`, `-3` before its extension, and the output lists each rename. - A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it. diff --git a/testfiles/mail/gog-roundcube.eml.expect b/testfiles/mail/gog-roundcube.eml.expect new file mode 100644 index 0000000..257a77e --- /dev/null +++ b/testfiles/mail/gog-roundcube.eml.expect @@ -0,0 +1,3 @@ +# the text is in the HTML part only; the plain-text part is a one-line placeholder +Silent Hill: Townfall +JETZT KAUFEN diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 53d84e2..8969962 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -1515,7 +1515,7 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`, - A single file directly in `incoming/` becomes a folder named after its stem (or `--name`) holding `original.` - the original bytes, moved - and `derived.md`. The fixed names keep the path inside the path budget; the header of `derived.md` records the file's own name as `original_name`. - A folder is derived in place: each file with a filter gets `.md` beside it, nothing is renamed, and the files without a filter are named in the output. - `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version. -- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a package in `tools/requirements.txt` that is imported only when such a file is derived. A missing package refuses that format alone, with the preflight hint; `wikitool doctor` shows one `import-filter-` line per package-backed format. A PDF gets `` markers and a WARN when it yields almost no text (a scan; there is no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an embedded message inside it is named, not read. +- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a package in `tools/requirements.txt` that is imported only when such a file is derived. A missing package refuses that format alone, with the preflight hint; `wikitool doctor` shows one `import-filter-` line per package-backed format. A PDF gets `` markers and a WARN when it yields almost no text (a scan; there is no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an embedded message inside it is named, not read. The text of a mail (`.eml` and `.msg` alike) is its plain text, unless that is under 10% of the length of the HTML text - a "plain text version not available" placeholder - in which case it is the HTML text and a WARN names both lengths. The other version of the text in a `multipart/alternative` is not an attachment. - A workbook (`.xlsx`, `.xlsm`) gets its cells as files: beside its `.md` a folder `.sheets/` (`derived.sheets/` for a single file) holds one `NN-.tsv` per sheet - tab-separated per RFC 4180, UTF-8, row 1 of the file is row 1 of the sheet - and, for a sheet with formulas, `NN-.formulas.tsv` listing cell, formula and last stored value. The `.md` is the overview: one section per sheet with its range, the file names and a preview. Formulas are not recalculated; a shared formula is written out per cell, and one that cannot be is named `shared:` with a WARN. The folder counts against the path budget and is accepted with the rest; an existing one is never overwritten. - A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder `incoming//` holding its files under their own names, each with a `.md` where a filter reads it; the ZIP is removed and the output names it with its sha256. A mail becomes a folder with `original.eml`, `derived.md` and its attachments under their own names; a ZIP attached to it is unpacked once into a folder of its own, and an attached mail is quoted in `derived.md`, not unpacked. A ZIP inside a ZIP, an absolute or `..` path, a symbolic link, an encrypted entry, more than 1000 entries, a file over 25 MiB or more than 250 MiB in total refuse the whole call. Hidden entries (`__MACOSX/`, `.DS_Store`, anything starting with a dot) are named, not written. A name taken twice, or reserved (`original.*` and `derived.md` at the top of a mail's folder, `_capture.json` anywhere), gets `-2`, `-3` before its extension, and the output lists each rename. - A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it. diff --git a/tools/chemenu/commands/raw_cmd.py b/tools/chemenu/commands/raw_cmd.py index aa19094..ecd787a 100644 --- a/tools/chemenu/commands/raw_cmd.py +++ b/tools/chemenu/commands/raw_cmd.py @@ -2177,7 +2177,11 @@ def _derive_next(target: Path) -> str: "doctor` shows one `import-filter-` line per package-backed format. A PDF gets " "`` markers and a WARN when it yields almost no text (a scan; there is " "no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an " - "embedded message inside it is named, not read.", + "embedded message inside it is named, not read. The text of a mail (`.eml` and `.msg` " + "alike) is its plain text, unless that is under 10% of the length of the HTML text - a " + "\"plain text version not available\" placeholder - in which case it is the HTML text " + "and a WARN names both lengths. The other version of the text in a " + "`multipart/alternative` is not an attachment.", "A workbook (`.xlsx`, `.xlsm`) gets its cells as files: beside its `.md` a folder " "`.sheets/` (`derived.sheets/` for a single file) holds one `NN-.tsv` " "per sheet - tab-separated per RFC 4180, UTF-8, row 1 of the file is row 1 of the sheet " diff --git a/tools/chemenu/filters/mail.py b/tools/chemenu/filters/mail.py index 5e54a62..ae9cf6f 100644 --- a/tools/chemenu/filters/mail.py +++ b/tools/chemenu/filters/mail.py @@ -39,6 +39,24 @@ class ParsedMail: inline_images: int = 0 +PLAIN_MIN_SHARE = 0.10 + + +def choose_text(plain: str, html_text: str) -> tuple[str, bool, Optional[str]]: + """The mail text from its two versions, both stripped: the plain text, unless it is + shorter than `PLAIN_MIN_SHARE` of the HTML text (a "plain text version not available" + placeholder). Returns the text, whether it came from the HTML, and a warning if the + plain text was passed over. Shared by `.eml` and `.msg`.""" + if not plain: + return html_text, bool(html_text), None + if html_text and len(plain) < PLAIN_MIN_SHARE * len(html_text): + return html_text, True, ( + f"The plain text ({len(plain)} characters) is under {PLAIN_MIN_SHARE:.0%} of the HTML text " + f"({len(html_text)} characters); the text is taken from the HTML part." + ) + return plain, False, None + + def _header(msg: EmailMessage, name: str) -> str: try: return re.sub(r"\s+", " ", str(msg[name] or "")).strip() @@ -100,6 +118,36 @@ def _leaves(part: EmailMessage): yield from _leaves(child) +def _other_versions(msg: EmailMessage, body_part: Optional[EmailMessage]) -> set[int]: + """The text/plain and text/html leaves under the nearest `multipart/alternative` that holds + the chosen body part: other versions of the mail text, not attachments. A part that is + explicitly an attachment stays one.""" + if body_part is None: + return set() + + def holder(part: EmailMessage, alternative: Optional[EmailMessage]) -> Optional[EmailMessage]: + if part is body_part: + return alternative + if part.get_content_type() == "message/rfc822" or not part.is_multipart(): + return None + nearest = part if part.get_content_type() == "multipart/alternative" else alternative + for child in part.iter_parts(): + found = holder(child, nearest) + if found is not None: + return found + return None + + alternative = holder(msg, None) + if alternative is None: + return set() + return { + id(leaf) + for leaf in _leaves(alternative) + if leaf.get_content_type() in ("text/plain", "text/html") + and (leaf.get_content_disposition() or "").lower() != "attachment" + } + + def parse(data: bytes) -> ParsedMail: msg = message_from_bytes(data, policy=policy.default) if not isinstance(msg, EmailMessage) or not (msg.keys() or msg.get_payload()): @@ -113,15 +161,21 @@ def parse(data: bytes) -> ParsedMail: "subject": _header(msg, "subject"), "message_id": _header(msg, "message-id"), } - body_part: Optional[EmailMessage] = msg.get_body(preferencelist=("plain", "html")) - if body_part is None: + plain_part: Optional[EmailMessage] = msg.get_body(preferencelist=("plain",)) + html_part: Optional[EmailMessage] = msg.get_body(preferencelist=("html",)) + body_part: Optional[EmailMessage] = None + if plain_part is None and html_part is None: out.warnings.append("The mail has no text/plain or text/html body (RTF-only or empty); the text is empty.") - elif body_part.get_content_type() == "text/html": - out.body = web_capture.derive_text(_text_of(body_part), "").strip() else: - out.body = _text_of(body_part).replace("\r\n", "\n").replace("\r", "\n").strip() + plain = _text_of(plain_part).replace("\r\n", "\n").replace("\r", "\n").strip() if plain_part is not None else "" + html_text = web_capture.derive_text(_text_of(html_part), "").strip() if html_part is not None else "" + out.body, from_html, warning = choose_text(plain, html_text) + if warning: + out.warnings.append(warning) + body_part = html_part if from_html or plain_part is None else plain_part + versions = _other_versions(msg, body_part) for number, part in enumerate(_leaves(msg), 1): - if part is body_part or part is msg: + if part is body_part or part is msg or id(part) in versions: continue disposition = (part.get_content_disposition() or "").lower() if ( diff --git a/tools/chemenu/filters/msg.py b/tools/chemenu/filters/msg.py index 4001d73..03ce948 100644 --- a/tools/chemenu/filters/msg.py +++ b/tools/chemenu/filters/msg.py @@ -1,7 +1,8 @@ """Outlook `.msg` (a compound file), read with `olefile` (imported when one is derived). -Only what a reader of the mail needs: the headers, the body (plain text, else HTML) -and the attachments by name and bytes. The result is the same `ParsedMail` an `.eml` +Only what a reader of the mail needs: the headers, the body (plain text, else HTML; +HTML too when the plain text is under 10% of it - `mail.choose_text`) and the +attachments by name and bytes. The result is the same `ParsedMail` an `.eml` yields, so everything after the parsing is shared with `mail.py`. """ from __future__ import annotations @@ -17,7 +18,7 @@ from typing import Optional from chemenu import web_capture from chemenu.errors import ValidationError from chemenu.filters import register -from chemenu.filters.mail import Attachment, EmlFilter, ParsedMail +from chemenu.filters.mail import Attachment, EmlFilter, ParsedMail, choose_text _SUBJECT = "0037" _BODY = "1000" @@ -129,12 +130,12 @@ def _html_text(raw: bytes) -> str: def _body(message: _Message, out: ParsedMail) -> None: - plain = message.text(_BODY) + plain = message.text(_BODY).replace("\r\n", "\n").replace("\r", "\n").strip() html = message.raw(_HTML) - if plain: - out.body = plain.replace("\r\n", "\n").replace("\r", "\n").strip() - elif html: - out.body = _html_text(html) + if plain or html: + out.body, _from_html, warning = choose_text(plain, _html_text(html) if html else "") + if warning: + out.warnings.append(warning) else: what = "has only an RTF body" if message.raw(_RTF) else "has no body" out.warnings.append(f"The message {what}, which raw derive does not read; the text is empty.") diff --git a/tools/chemenu/tests/test_raw_derive.py b/tools/chemenu/tests/test_raw_derive.py index d6c36d5..3a7af6e 100644 --- a/tools/chemenu/tests/test_raw_derive.py +++ b/tools/chemenu/tests/test_raw_derive.py @@ -182,8 +182,8 @@ def _text_cell(ref: str, text: str) -> str: return f'{text.replace(chr(13), " ")}' -def make_eml(body: str = "Hallo Welt\n", *, html: str = "", attach: bool = False, inline: bool = False, - subject: str = "Angebot") -> bytes: +def make_eml(body: str = "Hallo Welt\n", *, html: str = "", alternative: str = "", attach: bool = False, + inline: bool = False, subject: str = "Angebot") -> bytes: msg = EmailMessage() msg["From"] = "Anna Beispiel " msg["To"] = "bob@example.org, Carla " @@ -194,6 +194,8 @@ def make_eml(body: str = "Hallo Welt\n", *, html: str = "", attach: bool = False msg.set_content(html, subtype="html") else: msg.set_content(body) + if alternative: + msg.add_alternative(alternative, subtype="html") if inline: msg.add_related(b"\x89PNG", maintype="image", subtype="png", cid="") if attach: @@ -752,6 +754,87 @@ def test_eml_with_inline_image_warns_and_is_not_an_attachment(tree, capsys): assert "inline image" in capsys.readouterr().out +_NEWSLETTER = "

Newsletter

" + "Real content of the mail. " * 20 + "

" + + +def _eml_parsed(data: bytes): + from chemenu.filters import mail + + return mail.parse(data) + + +def test_eml_placeholder_plain_text_gives_way_to_the_html_text(tree, capsys): + _put(tree, "mail.eml", make_eml("Plain text version not available", alternative=_NEWSLETTER)) + _derive("incoming/mail.eml") + out = capsys.readouterr().out + header, text = _header_and_text(tree / "incoming/mail/derived.md") + + assert "Real content of the mail." in text and "not available" not in text + assert "The plain text (32 characters) is under 10% of the HTML text" in out + assert header["attachments"] == [] + assert sorted(p.name for p in (tree / "incoming/mail").iterdir()) == ["derived.md", "original.eml"] + + +def test_eml_good_plain_text_wins_over_html_without_a_warning(tree, capsys): + plain = "Real content of the mail. " * 18 + _put(tree, "mail.eml", make_eml(plain, alternative=_NEWSLETTER)) + _derive("incoming/mail.eml") + out = capsys.readouterr().out + header, text = _header_and_text(tree / "incoming/mail/derived.md") + + assert text.strip() == plain.strip() + assert "is under" not in out + assert header["attachments"] == [] + + +def test_eml_keeps_a_real_attachment_beside_the_two_text_versions(tree): + _put(tree, "mail.eml", make_eml("Plain text version not available", alternative=_NEWSLETTER, attach=True)) + _derive("incoming/mail.eml") + header, _text = _header_and_text(tree / "incoming/mail/derived.md") + + assert header["attachments"] == ["a.bin"] + assert (tree / "incoming/mail/a.bin").read_bytes() == b"data" + + +def test_eml_html_inside_a_related_group_is_still_a_text_version_and_its_image_is_inline(): + msg = EmailMessage() + msg["Subject"] = "S" + msg.set_content("Plain text version not available") + msg.add_alternative(_NEWSLETTER, subtype="html") + msg.get_payload()[1].add_related(b"\x89PNG", maintype="image", subtype="png", cid="") + parsed = _eml_parsed(msg.as_bytes()) + + assert "Real content of the mail." in parsed.body + assert parsed.attachments == [] + assert parsed.inline_images == 1 + + +def test_eml_text_version_marked_as_an_attachment_stays_one(): + msg = EmailMessage() + msg["Subject"] = "S" + msg.set_content("Hello there\n") + msg.add_alternative(_NEWSLETTER, subtype="html") + msg.get_payload()[1].add_header("Content-Disposition", "attachment", filename="version.html") + parsed = _eml_parsed(msg.as_bytes()) + + assert parsed.body == "Hello there" + assert [a.name for a in parsed.attachments] == ["version.html"] + + +def test_eml_plain_and_html_in_a_mixed_mail_without_alternative_keep_the_html_as_attachment(): + msg = EmailMessage() + msg["Subject"] = "S" + msg.set_content("Hello there. " * 40) + msg.make_mixed() + html = EmailMessage() + html.set_content(_NEWSLETTER, subtype="html") + msg.attach(html) + parsed = _eml_parsed(msg.as_bytes()) + + assert parsed.body == ("Hello there. " * 40).strip() + assert len(parsed.attachments) == 1 + + # --- the registry ----------------------------------------------------------- diff --git a/tools/chemenu/tests/test_raw_derive_binary.py b/tools/chemenu/tests/test_raw_derive_binary.py index 929d649..63a1c1f 100644 --- a/tools/chemenu/tests/test_raw_derive_binary.py +++ b/tools/chemenu/tests/test_raw_derive_binary.py @@ -165,6 +165,32 @@ def test_a_msg_with_only_html_takes_the_html_text(tree): assert "Only" in text and "html here" in text and "

" not in text +_HTML_BODY = "

" + "Real content of the mail. " * 20 + "

" + + +@needs_olefile +def test_a_msg_with_a_placeholder_plain_text_takes_the_html_text_and_warns(tree, capsys): + _put(tree, "p.msg", build_msg(subject="S", body="Plain text version not available", html=_HTML_BODY)) + _derive("incoming/p.msg") + out = capsys.readouterr().out + _header, text = _header_and_text(tree / "incoming/p/derived.md") + + assert "Real content of the mail." in text and "not available" not in text + assert "The plain text (32 characters) is under 10% of the HTML text" in out + + +@needs_olefile +def test_a_msg_with_a_good_plain_text_keeps_it_without_a_warning(tree, capsys): + plain = "Real content of the mail. " * 18 + _put(tree, "g.msg", build_msg(subject="S", body=plain, html=_HTML_BODY)) + _derive("incoming/g.msg") + out = capsys.readouterr().out + _header, text = _header_and_text(tree / "incoming/g/derived.md") + + assert text.strip() == plain.strip() + assert "is under" not in out + + @needs_olefile def test_a_msg_with_only_rtf_warns_and_has_no_text(tree, capsys): _put(tree, "r.msg", build_msg(subject="S", rtf=b"{\\rtf1 hello}"))