Created
February 20, 2026 20:55
-
-
Save Konfekt/4f9f08fbd23a341d3f10ff7514b95af3 to your computer and use it in GitHub Desktop.
View an HTML email with CID-embedded images in w3m from mutt/neomutt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| View an HTML email with CID-embedded images in w3m from mutt/neomutt using a | |
| macro in muttrc that pipes the whole message to this script such as | |
| macro index l "<pipe-message>mutt-view-html-with-images.py<enter>" "View HTML with images" | |
| Steps performed by the script: | |
| 1. Pipe the raw message to stdin. | |
| 2. Extract image parts to a temp directory. | |
| 3. Rewrite cid: URLs in HTML to extracted filenames. | |
| 4. Open HTML via w3m with working relative paths. | |
| 5. Clean up on exit. | |
| """ | |
| import email | |
| import mimetypes | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from email import policy | |
| def decode_text_payload(part: email.message.Message) -> str: | |
| try: | |
| content = part.get_content() | |
| if isinstance(content, str): | |
| return content | |
| payload = content if isinstance(content, (bytes, bytearray)) else b"" | |
| except Exception: | |
| payload = part.get_payload(decode=True) or b"" | |
| charset = part.get_content_charset() or "utf-8" | |
| try: | |
| return payload.decode(charset, errors="replace") | |
| except LookupError: | |
| return payload.decode("utf-8", errors="replace") | |
| def main() -> int: | |
| msg = email.message_from_bytes(sys.stdin.buffer.read(), policy=policy.default) | |
| w3m = shutil.which("w3m") | |
| if not w3m: | |
| print("w3m not found in PATH.", file=sys.stderr) | |
| return 1 | |
| with tempfile.TemporaryDirectory(prefix="mutt-html-") as tempdir: | |
| image_map: dict[str, str] = {} | |
| html_parts: list[str] = [] | |
| image_counter = 1 | |
| def unique_filename(name: str) -> str: | |
| base, ext = os.path.splitext(name) | |
| candidate = name | |
| n = 1 | |
| while os.path.exists(os.path.join(tempdir, candidate)): | |
| n += 1 | |
| candidate = f"{base}-{n}{ext}" | |
| return candidate | |
| for part in msg.walk(): | |
| if part.get_content_maintype() == "text" and part.get_content_subtype() == "html": | |
| html_parts.append(decode_text_payload(part)) | |
| continue | |
| if part.get_content_maintype() != "image": | |
| continue | |
| filename = os.path.basename(part.get_filename() or "") | |
| if not filename: | |
| ext = mimetypes.guess_extension(part.get_content_type()) or ".bin" | |
| filename = f"image{image_counter}{ext}" | |
| image_counter += 1 | |
| filename = unique_filename(filename) | |
| with open(os.path.join(tempdir, filename), "wb") as f: | |
| f.write(part.get_payload(decode=True) or b"") | |
| cid = part.get("Content-ID") | |
| if cid: | |
| image_map[cid.strip().strip("<>").strip()] = filename | |
| if not html_parts: | |
| print("No HTML part found in the message.", file=sys.stderr) | |
| return 1 | |
| html = html_parts[0] | |
| cid_pattern = re.compile(r"cid:\s*<?\s*([^\"'>\s]+)\s*>?", flags=re.IGNORECASE) | |
| def replace_cid(m: re.Match[str]) -> str: | |
| cid = m.group(1).strip().strip("<>").strip() | |
| return image_map.get(cid, m.group(0)) | |
| html = cid_pattern.sub(replace_cid, html) | |
| with open(os.path.join(tempdir, "body.html"), "w", encoding="utf-8") as f: | |
| f.write(html) | |
| # input("Press Enter to return to mutt...") | |
| proc = subprocess.run( | |
| [ | |
| w3m, | |
| "-F", | |
| "-o", | |
| "auto_image=1", | |
| "-o", | |
| "display_image=1", | |
| "-o", | |
| "ext_image_viewer=1", | |
| "-T", | |
| "text/html", | |
| "body.html", | |
| ], | |
| cwd=tempdir, | |
| ) | |
| return proc.returncode | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment