Compare commits

..

1 commit
main ... pkg

Author SHA1 Message Date
11cdc1c202 pkg 2025-06-04 12:55:56 -04:00
8 changed files with 414 additions and 248 deletions

View file

@ -1,20 +0,0 @@
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: based-alpine
steps:
- uses: actions/checkout@v4
- name: setup cache
id: uv-cache
uses: https://git.ficd.sh/ficd/uv-cache@v1
- name: build
run: |
uv sync
uv build
- name: publish
run: |
uv publish --token ${{ secrets.PYPI_TOKEN }}

View file

@ -1,21 +1,22 @@
<h1>Mail Format</h1>
`mailfmt` is a simple plain text email formatter. It's designed to ensure
consistent paragraph spacing while preserving markdown syntax, email
headers, sign-offs, and signature blocks.
consistent paragraph spacing while preserving markdown syntax, email headers,
sign-offs, and signature blocks.
By default, the command accepts its input on `stdin` and prints to
`stdout`. This makes it well suited for use as a formatter with a text
editor like Kakoune or Helix.
By default, this script accepts its input on `stdin` and prints to `stdout`.
This makes it well suited for use as a formatter with a text editor like Kakoune
or Helix. It has no dependencies besides the standard Python interpreter, and
was written and tested against Python 3.13.3.
<!--toc:start-->
- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
- [Output Example](#output-example)
- [Markdown Safety](#markdown-safety)
- [Aerc Integration](#aerc-integration)
- [Contributing](#contributing)
<!--toc:end-->
@ -32,27 +33,9 @@ editor like Kakoune or Helix.
- Markdown-style code blocks.
- Usenet-style signature block at EOF.
- Sign-offs.
- If specified, output can be made safe for passing to a Markdown
renderer.
- Use case: piping the output to `pandoc` to write a `text/html`
message. See [Markdown Safety](#markdown-safety).
## Installation
`mailfmt` is intended for use as a standaole tool. The package is
available on PyPI as `mailfmt`. I recommend using
[uv](https://github.com/astral-sh/uv) or `pipx` to install it so the
`mailfmt` command is available on your path:
```sh
uv tool install mailfmt
```
Verify that the installation was successful:
```sh
mailfmt --help
```
- If specified, output can be made safe for passing to a Markdown renderer.
- Use case: piping the output to `pandoc` to write a `text/html` message. See
[Markdown Safety](#markdown-safety).
## Usage
@ -80,7 +63,8 @@ options:
-o, --output OUTPUT Output file. (default: STDOUT)
Author : Daniel Fichtinger
Contact: daniel@ficd.sh
License: ISC
Contact: daniel@ficd.ca
```
## Output Example
@ -103,7 +87,8 @@ Daniel
--
Daniel
daniel@ficd.sh
sr.ht/~ficd
daniel@ficd.ca
```
After:
@ -125,24 +110,23 @@ Daniel
--
Daniel
daniel@ficd.sh
sr.ht/~ficd
daniel@ficd.ca
```
## Markdown Safety
In some cases, you may want to generate an HTML email. Ideally, you'd want
the HTML to be generated directly from the plain text message, and for
_both_ versions to be legible and have the same semantics.
In some cases, you may want to generate an HTML email. Ideally, you'd want the
HTML to be generated directly from the plain text message, and for _both_
versions to be legible and have the same semantics.
Although `mailfmt` was written with Markdown markup in mind, its intended
output is still the `text/plain` format. If you pass its output directly
to a Markdown renderer, line breaks in sign-offs and the signature block
won't be preserved.
Although `mailfmt` was written with Markdown markup in mind, its intended output
is still the `text/plain` format. If you pass its output directly to a Markdown
renderer, line breaks in sign-offs and the signature block won't be preserved.
If you invoke `mailfmt --markdown-safe`, then `\` characters will be
appended to mark line breaks that would otherwise be squashed, making the
output suitable for conversion into HTML. Here's an example of one such
pipeline:
If you invoke `mailfmt --markdown-safe`, then `\` characters will be appended to
mark line breaks that would otherwise be squashed, making the output suitable
for conversion into HTML. Here's an example of one such pipeline:
```bash
cat message.txt | mailfmt --markdown-safe | pandoc -f markdown -t html
@ -168,20 +152,24 @@ Daniel \
-- \
Daniel \
daniel@ficd.sh \
sr.ht/~ficd \
daniel@ficd.ca \
```
## Aerc Integration
For integration with `aerc`, consider adding the following to your
`aerc.conf`:
For integration with `aerc`, consider adding the following to your `aerc.conf`:
```ini
[multipart-converters]
text/html=mailfmt --markdown-safe | pandoc -f markdown -t html --standalone
```
When you're done writing your email, you can call the
`:multipart text/html` command to generate a `multipart/alternative`
message which includes _both_ your original `text/plain` _and_ the newly
generated `text/html` content.
When you're done writing your email, you can call the `:multipart text/html`
command to generate a `multipart/alternative` message which includes _both_ your
original `text/plain` _and_ the newly generated `text/html` content.
## Contributing
Please send patches, requests, and concerns to my
[public inbox](https://lists.sr.ht/~ficd/public-inbox).

View file

@ -1,21 +0,0 @@
clean:
#!/bin/sh
if [ ! -d "dist" ] && [ ! -d "__pycache__" ]; then
echo "Nothing to clean."
exit 0
fi
if [ -d "dist" ]; then
echo "Removing dist/"
rm -r dist/
fi
if [ -d "__pycache__" ]; then
echo "Removing __pycache__/"
rm -r "__pycache__"
fi
publish:
#!/bin/sh
just clean
uv build
export UV_PUBLISH_TOKEN="$(pass show pypi)"
uv publish

View file

@ -11,11 +11,10 @@
# Author: Daniel Fichtinger
# License: ISC
import argparse
import re
import sys
import textwrap
from importlib.metadata import version
import sys
import re
import argparse
def main() -> None:
@ -76,7 +75,7 @@ def main() -> None:
if not signoff_cache and 1 <= n <= 5 and line[-1] == ",":
return True
# second potential line
elif signoff_cache and 1 <= n <= 5:
elif signoff_cache and 1 <= n <= 5 and line[-1].isalpha():
for w in words:
if not w[0].isupper():
return False
@ -84,156 +83,146 @@ def main() -> None:
else:
return False
parser = argparse.ArgumentParser(
description="Heuristic formatter for plain text email. Preserves markup, signoffs, and signature blocks.",
epilog="""
Author : Daniel Fichtinger
Repository: https://git.ficd.sh/ficd/mailfmt
License : ISC
Contact : daniel@ficd.sh
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-v",
"--version",
required=False,
help="Print version info and exit.",
action="store_true",
)
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(
"-m",
"--markdown-safe",
required=False,
help="Output format safe for Markdown rendering.",
action="store_true",
)
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()
if args.version:
print(version("mailfmt"))
exit(0)
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
markdown_safe = args.markdown_safe
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: ISC
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(
"-m",
"--markdown-safe",
required=False,
help="Output format safe for Markdown rendering.",
action="store_true",
)
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
markdown_safe = args.markdown_safe
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")
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:
in_signoff = True
if not signoff_cache:
signoff_cache = line
else:
pprint(signoff_cache)
pprint(line)
in_signoff = False
for line in reader:
line = line.rstrip()
if should_check_signoff:
is_signoff = check_signoff(line)
if is_signoff:
in_signoff = True
if not signoff_cache:
signoff_cache = line
else:
pprint(signoff_cache)
pprint(line)
in_signoff = False
signoff_cache = ""
continue
elif not is_signoff and signoff_cache:
paragraph.append(signoff_cache)
signoff_cache = ""
continue
elif not is_signoff and signoff_cache:
in_signoff = False
if line.startswith("```"):
flush_paragraph()
skipping = not skipping
pprint(line)
elif should_check_signature and line == "--":
flush_paragraph()
skipping = True
in_signature = 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 = ""
in_signoff = False
if line.startswith("```"):
flush_paragraph()
skipping = not skipping
pprint(line)
elif should_check_signature and line == "--":
flush_paragraph()
skipping = True
in_signature = 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()
if out_stream is not None:
out_stream.close()
if __name__ == "__main__":

View file

@ -1,19 +1,13 @@
[project]
name = "mailfmt"
version = "1.0.4"
description = "Heuristic plain text email formatter."
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Daniel Fichtinger", email = "daniel@ficd.sh" }
{ name = "Daniel Fichtinger", email = "daniel@ficd.ca" }
]
requires-python = ">=3.11"
dependencies = []
license = "ISC"
license-files = ["LICENSE"]
keywords = ["email", "formatter", "cli"]
[project.urls]
Repository = "https://git.ficd.sh/ficd/mailfmt"
[project.scripts]
mailfmt = "mailfmt:main"

6
src/mailfmt/__init__.py Normal file
View file

@ -0,0 +1,6 @@
from . import mailfmt
def main() -> None:
print("Hello from mail format")
mailfmt.mailfmt()

230
src/mailfmt/mailfmt.py Executable file
View file

@ -0,0 +1,230 @@
#!/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.
# Signoff heuristic:
# 1-5 words ending with a comma, followed by
# 1-5 words that each start with capital letters.
# Author: Daniel Fichtinger
# License: ISC
import textwrap
import sys
import re
import argparse
def mailfmt() -> None:
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
markdown_safe = False
in_signoff = False
in_signature = False
def pprint(string: str):
if markdown_safe and (in_signoff or in_signature) and string:
string += " \\"
if not squash:
print(string, file=out_stream)
else:
parbreak = not string
nonlocal 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
parser = argparse.ArgumentParser(
description='Formatter for plain text email.\n"--no-*" options are NOT passed by default.',
epilog="""
Author : Daniel Fichtinger
License: ISC
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(
"-m",
"--markdown-safe",
required=False,
help="Output format safe for Markdown rendering.",
action="store_true",
)
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
markdown_safe = args.markdown_safe
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:
in_signoff = True
if not signoff_cache:
signoff_cache = line
else:
pprint(signoff_cache)
pprint(line)
in_signoff = False
signoff_cache = ""
continue
elif not is_signoff and signoff_cache:
paragraph.append(signoff_cache)
signoff_cache = ""
in_signoff = False
if line.startswith("```"):
flush_paragraph()
skipping = not skipping
pprint(line)
elif should_check_signature and line == "--":
flush_paragraph()
skipping = True
in_signature = 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()
if __name__ == "__main__":
mailfmt()
# pyright: basic

2
uv.lock generated
View file

@ -4,5 +4,5 @@ requires-python = ">=3.11"
[[package]]
name = "mailfmt"
version = "1.0.3"
version = "0.1.0"
source = { editable = "." }