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/.gitignore b/.gitignore
new file mode 100644
index 0000000..729a846
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,177 @@
+typings/
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+#uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..2c07333
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.11
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/install.sh b/install.sh
deleted file mode 100755
index f72f4c4..0000000
--- a/install.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/sh
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-sudo cp "$SCRIPT_DIR/mailfmt" /usr/bin/mailfmt
-echo "mailfmt has been installed to /usr/bin/mailfmt."
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 b/mailfmt.py
similarity index 65%
rename from mailfmt
rename to mailfmt.py
index a12e4c7..995e9aa 100755
--- a/mailfmt
+++ b/mailfmt.py
@@ -11,92 +11,96 @@
# Author: Daniel Fichtinger
# License: ISC
-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
-markdown_safe = False
-
-in_signoff = False
-in_signature = False
+import re
+import sys
+import textwrap
+from importlib.metadata import version
-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
- global prev_is_parbreak
- if skipping or not (parbreak and prev_is_parbreak):
+def main() -> 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)
- 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()
+ 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,
+ )
-signoff_cache: str = ""
+ 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
+ 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:
+ 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.',
+ description="Heuristic formatter for plain text email. Preserves markup, signoffs, and signature blocks.",
epilog="""
-Author : Daniel Fichtinger
-License: ISC
-Contact: daniel@ficd.ca
+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",
@@ -166,6 +170,9 @@ Contact: daniel@ficd.ca
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
@@ -228,4 +235,8 @@ Contact: daniel@ficd.ca
if out_stream is not None:
out_stream.close()
+
+if __name__ == "__main__":
+ main()
+
# pyright: basic
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5694f65
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "mailfmt"
+version = "1.0.4"
+description = "Heuristic plain text email formatter."
+readme = "README.md"
+authors = [
+ { 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"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
diff --git a/uninstall.sh b/uninstall.sh
deleted file mode 100755
index e995aa0..0000000
--- a/uninstall.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-
-sudo rm /usr/bin/mailfmt
-echo "mailfmt has been uninstalled."
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..029043b
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,8 @@
+version = 1
+revision = 2
+requires-python = ">=3.11"
+
+[[package]]
+name = "mailfmt"
+version = "1.0.3"
+source = { editable = "." }