diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml
new file mode 100644
index 0000000..fde3206
--- /dev/null
+++ b/.forgejo/workflows/publish.yml
@@ -0,0 +1,20 @@
+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 }}
+
diff --git a/README.md b/README.md
index e09b662..ff2c780 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,21 @@
Mail Format
`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, 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.
+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.
- [Features](#features)
+- [Installation](#installation)
- [Usage](#usage)
- [Output Example](#output-example)
- [Markdown Safety](#markdown-safety)
- [Aerc Integration](#aerc-integration)
-- [Contributing](#contributing)
@@ -33,9 +32,27 @@ was written and tested against Python 3.13.3.
- 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).
+- 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
+```
## Usage
@@ -63,8 +80,7 @@ options:
-o, --output OUTPUT Output file. (default: STDOUT)
Author : Daniel Fichtinger
-License: ISC
-Contact: daniel@ficd.ca
+Contact: daniel@ficd.sh
```
## Output Example
@@ -87,8 +103,7 @@ Daniel
--
Daniel
-sr.ht/~ficd
-daniel@ficd.ca
+daniel@ficd.sh
```
After:
@@ -110,23 +125,24 @@ Daniel
--
Daniel
-sr.ht/~ficd
-daniel@ficd.ca
+daniel@ficd.sh
```
## 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
@@ -152,24 +168,20 @@ Daniel \
-- \
Daniel \
-sr.ht/~ficd \
-daniel@ficd.ca \
+daniel@ficd.sh \
```
## 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.
-
-## Contributing
-
-Please send patches, requests, and concerns to my
-[public inbox](https://lists.sr.ht/~ficd/public-inbox).
+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.
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..b0828ed
--- /dev/null
+++ b/justfile
@@ -0,0 +1,21 @@
+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
+
diff --git a/mailfmt.py b/mailfmt.py
index fadc3dc..995e9aa 100755
--- a/mailfmt.py
+++ b/mailfmt.py
@@ -11,10 +11,11 @@
# Author: Daniel Fichtinger
# License: ISC
-import textwrap
-import sys
-import re
import argparse
+import re
+import sys
+import textwrap
+from importlib.metadata import version
def main() -> None:
@@ -75,7 +76,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 and line[-1].isalpha():
+ elif signoff_cache and 1 <= n <= 5:
for w in words:
if not w[0].isupper():
return False
@@ -83,146 +84,156 @@ def main() -> None:
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: 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
+ 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 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
- signoff_cache = ""
- continue
- elif not is_signoff and signoff_cache:
- paragraph.append(signoff_cache)
- signoff_cache = ""
+ 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
- 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:
+ signoff_cache = ""
+ continue
+ elif not is_signoff and signoff_cache:
paragraph.append(signoff_cache)
signoff_cache = ""
+ in_signoff = False
+ if line.startswith("```"):
flush_paragraph()
- if out_stream is not None:
- out_stream.close()
+ 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__":
diff --git a/pyproject.toml b/pyproject.toml
index d8dedf2..5694f65 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,13 +1,19 @@
[project]
name = "mailfmt"
-version = "0.1.0"
-description = "Add your description here"
+version = "1.0.4"
+description = "Heuristic plain text email formatter."
readme = "README.md"
authors = [
- { name = "Daniel Fichtinger", email = "daniel@ficd.ca" }
+ { name = "Daniel Fichtinger", email = "daniel@ficd.sh" }
]
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"
diff --git a/src/mailfmt/__init__.py b/src/mailfmt/__init__.py
deleted file mode 100644
index ffdcf09..0000000
--- a/src/mailfmt/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from . import mailfmt
-
-
-def main() -> None:
- print("Hello from mail format")
- mailfmt.mailfmt()
diff --git a/src/mailfmt/mailfmt.py b/src/mailfmt/mailfmt.py
deleted file mode 100755
index 2a1f964..0000000
--- a/src/mailfmt/mailfmt.py
+++ /dev/null
@@ -1,230 +0,0 @@
-#!/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
diff --git a/uv.lock b/uv.lock
index 6688ee9..029043b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4,5 +4,5 @@ requires-python = ">=3.11"
[[package]]
name = "mailfmt"
-version = "0.1.0"
+version = "1.0.3"
source = { editable = "." }