From c81b2e8d71e523768728d967b1363f51cafe2721 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Tue, 3 Jun 2025 13:24:55 -0400 Subject: [PATCH] init --- README.md | 44 +++++++++++ mailfmt.py | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 README.md create mode 100755 mailfmt.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..183538b --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Mail Format + +By default, this script accepts its input on `stdin` and prints to `stdout`. +This makes it well suited for use with an editor like Helix. It has no +dependencies besides the standard Python interpreter, and was written and tested +against Python 3.13.2. + +**Features:** + +- Wraps emails at specified columns. +- Automatically reflows paragraphs. +- Squashes consecutive paragraph breaks. +- Preserves: + - Any long word not broken by spaces (e.g. URLs, email addresses). + - Quoted lines. + - Indented lines. + - Lists. + - Markdown-style code blocks. + - Usenet-style signature block at EOF. + - Sign-offs. + +**Usage:** + +``` +usage: format.py [-h] [-w WIDTH] [-b] [--no-replace-whitespace] [--no-reflow] + [--no-signoff] [--no-signature] [--no-squash] [-i INPUT] [-o OUTPUT] + +Formatter for plain text email. +"--no-*" options are NOT passed by default. + +options: + -h, --help show this help message and exit + -w, --width WIDTH Text width for wrapping. (default: 74) + -b, --break-long-words + Break long words while wrapping. (default: False) + --no-replace-whitespace + Don't normalize whitespace when wrapping. + --no-reflow Don't reflow lines. + --no-signoff Don't preserve signoff line breaks. + --no-signature Don't preserve signature block. + --no-squash Don't squash consecutive paragraph breaks. + -i, --input INPUT Input file. (default: STDIN) + -o, --output OUTPUT Output file. (default: STDOUT) +``` diff --git a/mailfmt.py b/mailfmt.py new file mode 100755 index 0000000..81e7a7f --- /dev/null +++ b/mailfmt.py @@ -0,0 +1,213 @@ +#!/bin/env python + +# Simple text-wrapping script for email. +# Preserves code blocks, quotes, and signature. +# Automatically joins and re-wraps paragraphs to +# ensure even spacing & avoid ugly wrapping. +# Preserves signoffs, eg: Yours truly,\nJohn Doe +# Signoff heuristic: +# 1-5 words ending with a comma, followed by +# 1-5 words that each start with capital letters. +# Author: Daniel Fichtinger +# License: MIT + +import textwrap +import sys +import re +import argparse + +paragraph: list[str] = [] +skipping = False +squash = True +prev_is_parbreak = False +out_stream = sys.stdout +reflow = True +width = 74 +break_long_words = False +replace_whitespace = True + + +def pprint(string: str): + if not squash: + print(string, file=out_stream) + else: + parbreak = not string + global prev_is_parbreak + if skipping or not (parbreak and prev_is_parbreak): + print(string, file=out_stream) + prev_is_parbreak = parbreak + + +def wrap(text: str): + return textwrap.wrap( + text, + width=width, + break_long_words=break_long_words, + replace_whitespace=replace_whitespace, + ) + + +def flush_paragraph(): + if paragraph: + if reflow: + joined = " ".join(paragraph) + wrapped = wrap(joined) + pprint("\n".join(wrapped)) + else: + for line in paragraph: + for wrapped_line in wrap(line): + pprint(wrapped_line) + paragraph.clear() + + +signoff_cache: str = "" + + +def check_signoff(line: str) -> bool: + if not line: + return False + words = line.split() + n = len(words) + # first potential signoff line + if not signoff_cache and 1 <= n <= 5 and line[-1] == ",": + return True + # second potential line + elif signoff_cache and 1 <= n <= 5 and line[-1].isalpha(): + for w in words: + if not w[0].isupper(): + return False + return True + else: + return False + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description='Formatter for plain text email.\n"--no-*" options are NOT passed by default.', + epilog=""" +Author : Daniel Fichtinger +License: MIT +Contact: daniel@ficd.ca + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-w", + "--width", + required=False, + help="Text width for wrapping. (default: %(default)s)", + default=width, + type=int, + ) + parser.add_argument( + "-b", + "--break-long-words", + required=False, + help="Break long words while wrapping. (default: %(default)s)", + action="store_true", + ) + parser.add_argument( + "--no-replace-whitespace", + required=False, + help="Don't normalize whitespace when wrapping.", + action="store_false", + ) + parser.add_argument( + "--no-reflow", + required=False, + help="Don't reflow lines.", + action="store_false", + ) + parser.add_argument( + "--no-signoff", + required=False, + help="Don't preserve signoff line breaks.", + action="store_false", + ) + parser.add_argument( + "--no-signature", + required=False, + help="Don't preserve signature block.", + action="store_false", + ) + parser.add_argument( + "--no-squash", + required=False, + help="Don't squash consecutive paragraph breaks.", + action="store_false", + ) + parser.add_argument( + "-i", + "--input", + required=False, + type=str, + default="STDIN", + help="Input file. (default: %(default)s)", + ) + parser.add_argument( + "-o", + "--output", + required=False, + type=str, + default="STDOUT", + help="Output file. (default: %(default)s)", + ) + args = parser.parse_args() + width = args.width + should_check_signoff = args.no_signoff + should_check_signature = args.no_signature + reflow = args.no_reflow + squash = args.no_squash + replace_whitespace = args.no_replace_whitespace + break_long_words = args.break_long_words + + if args.input == "STDIN": + reader = sys.stdin + else: + with open(args.input, "r") as in_stream: + reader = in_stream + if args.output != "STDOUT": + out_stream = open(args.output, "w") + + for line in reader: + line = line.rstrip() + if should_check_signoff: + is_signoff = check_signoff(line) + if is_signoff: + if not signoff_cache: + signoff_cache = line + else: + pprint(signoff_cache) + pprint(line) + signoff_cache = "" + continue + elif not is_signoff and signoff_cache: + paragraph.append(signoff_cache) + signoff_cache = "" + if line.startswith("```"): + flush_paragraph() + skipping = not skipping + pprint(line) + elif should_check_signature and line == "--": + flush_paragraph() + skipping = True + pprint("-- ") + elif not line or re.match( + r"^(\s+|-\s+|\+\s+|\*\s+|>\s*|#\s+|From:|To:|Cc:|Bcc:|Subject:|Reply-To:|In-Reply-To:|References:|Date:|Message-Id:|User-Agent:)", + line, + ): + flush_paragraph() + pprint(line) + elif skipping: + pprint(line) + else: + paragraph.append(line) + else: + if signoff_cache: + paragraph.append(signoff_cache) + signoff_cache = "" + flush_paragraph() + if out_stream is not None: + out_stream.close() + +# pyright: basic