mailfmt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mailfmt-0.1.0.dist-info/METADATA +184 -0
- mailfmt-0.1.0.dist-info/RECORD +6 -0
- mailfmt-0.1.0.dist-info/WHEEL +4 -0
- mailfmt-0.1.0.dist-info/entry_points.txt +2 -0
- mailfmt-0.1.0.dist-info/licenses/LICENSE +13 -0
- mailfmt.py +230 -0
@@ -0,0 +1,184 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: mailfmt
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Plain text email formatter
|
5
|
+
Author-email: Daniel Fichtinger <daniel@ficd.ca>
|
6
|
+
License-File: LICENSE
|
7
|
+
Requires-Python: >=3.11
|
8
|
+
Description-Content-Type: text/markdown
|
9
|
+
|
10
|
+
<h1>Mail Format</h1>
|
11
|
+
|
12
|
+
`mailfmt` is a simple plain text email formatter. It's designed to ensure
|
13
|
+
consistent paragraph spacing while preserving markdown syntax, email headers,
|
14
|
+
sign-offs, and signature blocks.
|
15
|
+
|
16
|
+
By default, this script accepts its input on `stdin` and prints to `stdout`.
|
17
|
+
This makes it well suited for use as a formatter with a text editor like Kakoune
|
18
|
+
or Helix. It has no dependencies besides the standard Python interpreter, and
|
19
|
+
was written and tested against Python 3.13.3.
|
20
|
+
|
21
|
+
<!--toc:start-->
|
22
|
+
|
23
|
+
- [Features](#features)
|
24
|
+
- [Usage](#usage)
|
25
|
+
- [Output Example](#output-example)
|
26
|
+
- [Markdown Safety](#markdown-safety)
|
27
|
+
- [Aerc Integration](#aerc-integration)
|
28
|
+
- [Contributing](#contributing)
|
29
|
+
|
30
|
+
<!--toc:end-->
|
31
|
+
|
32
|
+
## Features
|
33
|
+
|
34
|
+
- Wraps emails at specified columns.
|
35
|
+
- Automatically reflows paragraphs.
|
36
|
+
- Squashes consecutive paragraph breaks.
|
37
|
+
- Preserves:
|
38
|
+
- Any long word not broken by spaces (e.g. URLs, email addresses).
|
39
|
+
- Quoted lines.
|
40
|
+
- Indented lines.
|
41
|
+
- Lists.
|
42
|
+
- Markdown-style code blocks.
|
43
|
+
- Usenet-style signature block at EOF.
|
44
|
+
- Sign-offs.
|
45
|
+
- If specified, output can be made safe for passing to a Markdown renderer.
|
46
|
+
- Use case: piping the output to `pandoc` to write a `text/html` message. See
|
47
|
+
[Markdown Safety](#markdown-safety).
|
48
|
+
|
49
|
+
## Usage
|
50
|
+
|
51
|
+
```
|
52
|
+
usage: mailfmt [-h] [-w WIDTH] [-b] [--no-replace-whitespace] [--no-reflow]
|
53
|
+
[--no-signoff] [--no-signature] [--no-squash] [-m] [-i INPUT]
|
54
|
+
[-o OUTPUT]
|
55
|
+
|
56
|
+
Formatter for plain text email.
|
57
|
+
"--no-*" options are NOT passed by default.
|
58
|
+
|
59
|
+
options:
|
60
|
+
-h, --help show this help message and exit
|
61
|
+
-w, --width WIDTH Text width for wrapping. (default: 74)
|
62
|
+
-b, --break-long-words
|
63
|
+
Break long words while wrapping. (default: False)
|
64
|
+
--no-replace-whitespace
|
65
|
+
Don't normalize whitespace when wrapping.
|
66
|
+
--no-reflow Don't reflow lines.
|
67
|
+
--no-signoff Don't preserve signoff line breaks.
|
68
|
+
--no-signature Don't preserve signature block.
|
69
|
+
--no-squash Don't squash consecutive paragraph breaks.
|
70
|
+
-m, --markdown-safe Output format safe for Markdown rendering.
|
71
|
+
-i, --input INPUT Input file. (default: STDIN)
|
72
|
+
-o, --output OUTPUT Output file. (default: STDOUT)
|
73
|
+
|
74
|
+
Author : Daniel Fichtinger
|
75
|
+
License: ISC
|
76
|
+
Contact: daniel@ficd.ca
|
77
|
+
```
|
78
|
+
|
79
|
+
## Output Example
|
80
|
+
|
81
|
+
Before:
|
82
|
+
|
83
|
+
```
|
84
|
+
Hey,
|
85
|
+
|
86
|
+
This is a really long paragraph with lots of words in it. However, my text editor uses soft-wrapping, so it ends up looking horrible when viewed without wrapping! Additionally,
|
87
|
+
if I manually add some line breaks, things start to look _super_ janky!
|
88
|
+
|
89
|
+
I can't just pipe this to `fmt` because it may break my beautiful
|
90
|
+
markdown
|
91
|
+
syntax. Markdown formatters are also problematic because they mess up
|
92
|
+
my signoff and signature blocks! What should I do?
|
93
|
+
|
94
|
+
Best wishes,
|
95
|
+
Daniel
|
96
|
+
|
97
|
+
--
|
98
|
+
Daniel
|
99
|
+
sr.ht/~ficd
|
100
|
+
daniel@ficd.ca
|
101
|
+
```
|
102
|
+
|
103
|
+
After:
|
104
|
+
|
105
|
+
```
|
106
|
+
Hey,
|
107
|
+
|
108
|
+
This is a really long paragraph with lots of words in it. However, my text
|
109
|
+
editor uses soft-wrapping, so it ends up looking horrible when viewed
|
110
|
+
without wrapping! Additionally, if I manually add some line breaks, things
|
111
|
+
start to look _super_ janky!
|
112
|
+
|
113
|
+
I can't just pipe this to `fmt` because it may break my beautiful markdown
|
114
|
+
syntax. Markdown formatters are also problematic because they mess up my
|
115
|
+
signoff and signature blocks! What should I do?
|
116
|
+
|
117
|
+
Best wishes,
|
118
|
+
Daniel
|
119
|
+
|
120
|
+
--
|
121
|
+
Daniel
|
122
|
+
sr.ht/~ficd
|
123
|
+
daniel@ficd.ca
|
124
|
+
```
|
125
|
+
|
126
|
+
## Markdown Safety
|
127
|
+
|
128
|
+
In some cases, you may want to generate an HTML email. Ideally, you'd want the
|
129
|
+
HTML to be generated directly from the plain text message, and for _both_
|
130
|
+
versions to be legible and have the same semantics.
|
131
|
+
|
132
|
+
Although `mailfmt` was written with Markdown markup in mind, its intended output
|
133
|
+
is still the `text/plain` format. If you pass its output directly to a Markdown
|
134
|
+
renderer, line breaks in sign-offs and the signature block won't be preserved.
|
135
|
+
|
136
|
+
If you invoke `mailfmt --markdown-safe`, then `\` characters will be appended to
|
137
|
+
mark line breaks that would otherwise be squashed, making the output suitable
|
138
|
+
for conversion into HTML. Here's an example of one such pipeline:
|
139
|
+
|
140
|
+
```bash
|
141
|
+
cat message.txt | mailfmt --markdown-safe | pandoc -f markdown -t html
|
142
|
+
--standalone > message.html
|
143
|
+
```
|
144
|
+
|
145
|
+
Here's the earlier example message with markdown safe output:
|
146
|
+
|
147
|
+
```
|
148
|
+
Hey,
|
149
|
+
|
150
|
+
This is a really long paragraph with lots of words in it. However, my text
|
151
|
+
editor uses soft-wrapping, so it ends up looking horrible when viewed
|
152
|
+
without wrapping! Additionally, if I manually add some line breaks, things
|
153
|
+
start to look _super_ janky!
|
154
|
+
|
155
|
+
I can't just pipe this to `fmt` because it may break my beautiful markdown
|
156
|
+
syntax. Markdown formatters are also problematic because they mess up my
|
157
|
+
signoff and signature blocks! What should I do?
|
158
|
+
|
159
|
+
Best wishes, \
|
160
|
+
Daniel \
|
161
|
+
|
162
|
+
-- \
|
163
|
+
Daniel \
|
164
|
+
sr.ht/~ficd \
|
165
|
+
daniel@ficd.ca \
|
166
|
+
```
|
167
|
+
|
168
|
+
## Aerc Integration
|
169
|
+
|
170
|
+
For integration with `aerc`, consider adding the following to your `aerc.conf`:
|
171
|
+
|
172
|
+
```ini
|
173
|
+
[multipart-converters]
|
174
|
+
text/html=mailfmt --markdown-safe | pandoc -f markdown -t html --standalone
|
175
|
+
```
|
176
|
+
|
177
|
+
When you're done writing your email, you can call the `:multipart text/html`
|
178
|
+
command to generate a `multipart/alternative` message which includes _both_ your
|
179
|
+
original `text/plain` _and_ the newly generated `text/html` content.
|
180
|
+
|
181
|
+
## Contributing
|
182
|
+
|
183
|
+
Please send patches, requests, and concerns to my
|
184
|
+
[public inbox](https://lists.sr.ht/~ficd/public-inbox).
|
@@ -0,0 +1,6 @@
|
|
1
|
+
mailfmt.py,sha256=auV0gcx7smsNhjbjdL8yNNDVEEidGMwJMB4AsUyBwbk,6500
|
2
|
+
mailfmt-0.1.0.dist-info/METADATA,sha256=oJkZ8gCdpMFJOyLXO7BZHYfVoL2x2zZlpRwhSo6fjLg,5587
|
3
|
+
mailfmt-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
4
|
+
mailfmt-0.1.0.dist-info/entry_points.txt,sha256=eFkLVHtmLEdRnOOzT4d2pAy7mBkjESW_8VQgUfAWvk4,41
|
5
|
+
mailfmt-0.1.0.dist-info/licenses/LICENSE,sha256=M8EhEEXHuRQsNgdRFtF2Ty6P62p8vgDanib1C-Zqkqc,749
|
6
|
+
mailfmt-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.ca>
|
2
|
+
|
3
|
+
Permission to use, copy, modify, and distribute this software for any
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
6
|
+
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
mailfmt.py
ADDED
@@ -0,0 +1,230 @@
|
|
1
|
+
#!/bin/env python
|
2
|
+
|
3
|
+
# Simple text-wrapping script for email.
|
4
|
+
# Preserves code blocks, quotes, and signature.
|
5
|
+
# Automatically joins and re-wraps paragraphs to
|
6
|
+
# ensure even spacing & avoid ugly wrapping.
|
7
|
+
# Preserves signoffs.
|
8
|
+
# Signoff heuristic:
|
9
|
+
# 1-5 words ending with a comma, followed by
|
10
|
+
# 1-5 words that each start with capital letters.
|
11
|
+
# Author: Daniel Fichtinger
|
12
|
+
# License: ISC
|
13
|
+
|
14
|
+
import textwrap
|
15
|
+
import sys
|
16
|
+
import re
|
17
|
+
import argparse
|
18
|
+
|
19
|
+
|
20
|
+
def main() -> None:
|
21
|
+
paragraph: list[str] = []
|
22
|
+
skipping = False
|
23
|
+
squash = True
|
24
|
+
prev_is_parbreak = False
|
25
|
+
out_stream = sys.stdout
|
26
|
+
reflow = True
|
27
|
+
width = 74
|
28
|
+
break_long_words = False
|
29
|
+
replace_whitespace = True
|
30
|
+
markdown_safe = False
|
31
|
+
|
32
|
+
in_signoff = False
|
33
|
+
in_signature = False
|
34
|
+
|
35
|
+
def pprint(string: str):
|
36
|
+
if markdown_safe and (in_signoff or in_signature) and string:
|
37
|
+
string += " \\"
|
38
|
+
if not squash:
|
39
|
+
print(string, file=out_stream)
|
40
|
+
else:
|
41
|
+
parbreak = not string
|
42
|
+
nonlocal prev_is_parbreak
|
43
|
+
if skipping or not (parbreak and prev_is_parbreak):
|
44
|
+
print(string, file=out_stream)
|
45
|
+
prev_is_parbreak = parbreak
|
46
|
+
|
47
|
+
def wrap(text: str):
|
48
|
+
return textwrap.wrap(
|
49
|
+
text,
|
50
|
+
width=width,
|
51
|
+
break_long_words=break_long_words,
|
52
|
+
replace_whitespace=replace_whitespace,
|
53
|
+
)
|
54
|
+
|
55
|
+
def flush_paragraph():
|
56
|
+
if paragraph:
|
57
|
+
if reflow:
|
58
|
+
joined = " ".join(paragraph)
|
59
|
+
wrapped = wrap(joined)
|
60
|
+
pprint("\n".join(wrapped))
|
61
|
+
else:
|
62
|
+
for line in paragraph:
|
63
|
+
for wrapped_line in wrap(line):
|
64
|
+
pprint(wrapped_line)
|
65
|
+
paragraph.clear()
|
66
|
+
|
67
|
+
signoff_cache: str = ""
|
68
|
+
|
69
|
+
def check_signoff(line: str) -> bool:
|
70
|
+
if not line:
|
71
|
+
return False
|
72
|
+
words = line.split()
|
73
|
+
n = len(words)
|
74
|
+
# first potential signoff line
|
75
|
+
if not signoff_cache and 1 <= n <= 5 and line[-1] == ",":
|
76
|
+
return True
|
77
|
+
# second potential line
|
78
|
+
elif signoff_cache and 1 <= n <= 5 and line[-1].isalpha():
|
79
|
+
for w in words:
|
80
|
+
if not w[0].isupper():
|
81
|
+
return False
|
82
|
+
return True
|
83
|
+
else:
|
84
|
+
return False
|
85
|
+
|
86
|
+
parser = argparse.ArgumentParser(
|
87
|
+
description='Formatter for plain text email.\n"--no-*" options are NOT passed by default.',
|
88
|
+
epilog="""
|
89
|
+
Author : Daniel Fichtinger
|
90
|
+
License: ISC
|
91
|
+
Contact: daniel@ficd.ca
|
92
|
+
""",
|
93
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
94
|
+
)
|
95
|
+
parser.add_argument(
|
96
|
+
"-w",
|
97
|
+
"--width",
|
98
|
+
required=False,
|
99
|
+
help="Text width for wrapping. (default: %(default)s)",
|
100
|
+
default=width,
|
101
|
+
type=int,
|
102
|
+
)
|
103
|
+
parser.add_argument(
|
104
|
+
"-b",
|
105
|
+
"--break-long-words",
|
106
|
+
required=False,
|
107
|
+
help="Break long words while wrapping. (default: %(default)s)",
|
108
|
+
action="store_true",
|
109
|
+
)
|
110
|
+
parser.add_argument(
|
111
|
+
"--no-replace-whitespace",
|
112
|
+
required=False,
|
113
|
+
help="Don't normalize whitespace when wrapping.",
|
114
|
+
action="store_false",
|
115
|
+
)
|
116
|
+
parser.add_argument(
|
117
|
+
"--no-reflow",
|
118
|
+
required=False,
|
119
|
+
help="Don't reflow lines.",
|
120
|
+
action="store_false",
|
121
|
+
)
|
122
|
+
parser.add_argument(
|
123
|
+
"--no-signoff",
|
124
|
+
required=False,
|
125
|
+
help="Don't preserve signoff line breaks.",
|
126
|
+
action="store_false",
|
127
|
+
)
|
128
|
+
parser.add_argument(
|
129
|
+
"--no-signature",
|
130
|
+
required=False,
|
131
|
+
help="Don't preserve signature block.",
|
132
|
+
action="store_false",
|
133
|
+
)
|
134
|
+
parser.add_argument(
|
135
|
+
"--no-squash",
|
136
|
+
required=False,
|
137
|
+
help="Don't squash consecutive paragraph breaks.",
|
138
|
+
action="store_false",
|
139
|
+
)
|
140
|
+
parser.add_argument(
|
141
|
+
"-m",
|
142
|
+
"--markdown-safe",
|
143
|
+
required=False,
|
144
|
+
help="Output format safe for Markdown rendering.",
|
145
|
+
action="store_true",
|
146
|
+
)
|
147
|
+
parser.add_argument(
|
148
|
+
"-i",
|
149
|
+
"--input",
|
150
|
+
required=False,
|
151
|
+
type=str,
|
152
|
+
default="STDIN",
|
153
|
+
help="Input file. (default: %(default)s)",
|
154
|
+
)
|
155
|
+
parser.add_argument(
|
156
|
+
"-o",
|
157
|
+
"--output",
|
158
|
+
required=False,
|
159
|
+
type=str,
|
160
|
+
default="STDOUT",
|
161
|
+
help="Output file. (default: %(default)s)",
|
162
|
+
)
|
163
|
+
args = parser.parse_args()
|
164
|
+
width = args.width
|
165
|
+
should_check_signoff = args.no_signoff
|
166
|
+
should_check_signature = args.no_signature
|
167
|
+
reflow = args.no_reflow
|
168
|
+
squash = args.no_squash
|
169
|
+
replace_whitespace = args.no_replace_whitespace
|
170
|
+
break_long_words = args.break_long_words
|
171
|
+
markdown_safe = args.markdown_safe
|
172
|
+
|
173
|
+
if args.input == "STDIN":
|
174
|
+
reader = sys.stdin
|
175
|
+
else:
|
176
|
+
with open(args.input, "r") as in_stream:
|
177
|
+
reader = in_stream
|
178
|
+
if args.output != "STDOUT":
|
179
|
+
out_stream = open(args.output, "w")
|
180
|
+
|
181
|
+
for line in reader:
|
182
|
+
line = line.rstrip()
|
183
|
+
if should_check_signoff:
|
184
|
+
is_signoff = check_signoff(line)
|
185
|
+
if is_signoff:
|
186
|
+
in_signoff = True
|
187
|
+
if not signoff_cache:
|
188
|
+
signoff_cache = line
|
189
|
+
else:
|
190
|
+
pprint(signoff_cache)
|
191
|
+
pprint(line)
|
192
|
+
in_signoff = False
|
193
|
+
signoff_cache = ""
|
194
|
+
continue
|
195
|
+
elif not is_signoff and signoff_cache:
|
196
|
+
paragraph.append(signoff_cache)
|
197
|
+
signoff_cache = ""
|
198
|
+
in_signoff = False
|
199
|
+
if line.startswith("```"):
|
200
|
+
flush_paragraph()
|
201
|
+
skipping = not skipping
|
202
|
+
pprint(line)
|
203
|
+
elif should_check_signature and line == "--":
|
204
|
+
flush_paragraph()
|
205
|
+
skipping = True
|
206
|
+
in_signature = True
|
207
|
+
pprint("-- ")
|
208
|
+
elif not line or re.match(
|
209
|
+
r"^(\s+|-\s+|\+\s+|\*\s+|>\s*|#\s+|From:|To:|Cc:|Bcc:|Subject:|Reply-To:|In-Reply-To:|References:|Date:|Message-Id:|User-Agent:)",
|
210
|
+
line,
|
211
|
+
):
|
212
|
+
flush_paragraph()
|
213
|
+
pprint(line)
|
214
|
+
elif skipping:
|
215
|
+
pprint(line)
|
216
|
+
else:
|
217
|
+
paragraph.append(line)
|
218
|
+
else:
|
219
|
+
if signoff_cache:
|
220
|
+
paragraph.append(signoff_cache)
|
221
|
+
signoff_cache = ""
|
222
|
+
flush_paragraph()
|
223
|
+
if out_stream is not None:
|
224
|
+
out_stream.close()
|
225
|
+
|
226
|
+
|
227
|
+
if __name__ == "__main__":
|
228
|
+
main()
|
229
|
+
|
230
|
+
# pyright: basic
|