Compare commits

...
Sign in to create a new pull request.

14 commits
pkg ... main

10 changed files with 380 additions and 116 deletions

View file

@ -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 }}

177
.gitignore vendored Normal file
View file

@ -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

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.11

View file

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

21
justfile Normal file
View file

@ -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

View file

@ -11,92 +11,96 @@
# Author: Daniel Fichtinger # Author: Daniel Fichtinger
# License: ISC # License: ISC
import textwrap
import sys
import re
import argparse import argparse
import re
paragraph: list[str] = [] import sys
skipping = False import textwrap
squash = True from importlib.metadata import version
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): def main() -> None:
if markdown_safe and (in_signoff or in_signature) and string: paragraph: list[str] = []
string += " \\" skipping = False
if not squash: squash = True
print(string, file=out_stream) prev_is_parbreak = False
else: out_stream = sys.stdout
parbreak = not string reflow = True
global prev_is_parbreak width = 74
if skipping or not (parbreak and prev_is_parbreak): 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) 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: else:
for line in paragraph: parbreak = not string
for wrapped_line in wrap(line): nonlocal prev_is_parbreak
pprint(wrapped_line) if skipping or not (parbreak and prev_is_parbreak):
paragraph.clear() 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: def check_signoff(line: str) -> bool:
if not line: if not line:
return False return False
words = line.split() words = line.split()
n = len(words) n = len(words)
# first potential signoff line # first potential signoff line
if not signoff_cache and 1 <= n <= 5 and line[-1] == ",": if not signoff_cache and 1 <= n <= 5 and line[-1] == ",":
return True return True
# second potential line # 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: for w in words:
if not w[0].isupper(): if not w[0].isupper():
return False return False
return True return True
else: else:
return False return False
if __name__ == "__main__":
parser = argparse.ArgumentParser( 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=""" epilog="""
Author : Daniel Fichtinger Author : Daniel Fichtinger
License: ISC Repository: https://git.ficd.sh/ficd/mailfmt
Contact: daniel@ficd.ca License : ISC
Contact : daniel@ficd.sh
""", """,
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument(
"-v",
"--version",
required=False,
help="Print version info and exit.",
action="store_true",
)
parser.add_argument( parser.add_argument(
"-w", "-w",
"--width", "--width",
@ -166,6 +170,9 @@ Contact: daniel@ficd.ca
help="Output file. (default: %(default)s)", help="Output file. (default: %(default)s)",
) )
args = parser.parse_args() args = parser.parse_args()
if args.version:
print(version("mailfmt"))
exit(0)
width = args.width width = args.width
should_check_signoff = args.no_signoff should_check_signoff = args.no_signoff
should_check_signature = args.no_signature should_check_signature = args.no_signature
@ -228,4 +235,8 @@ Contact: daniel@ficd.ca
if out_stream is not None: if out_stream is not None:
out_stream.close() out_stream.close()
if __name__ == "__main__":
main()
# pyright: basic # pyright: basic

23
pyproject.toml Normal file
View file

@ -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"

View file

@ -1,4 +0,0 @@
#!/bin/sh
sudo rm /usr/bin/mailfmt
echo "mailfmt has been uninstalled."

8
uv.lock generated Normal file
View file

@ -0,0 +1,8 @@
version = 1
revision = 2
requires-python = ">=3.11"
[[package]]
name = "mailfmt"
version = "1.0.3"
source = { editable = "." }