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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mailfmt = mailfmt:main
@@ -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