Compare commits

..

6 commits

Author SHA1 Message Date
1f413b099c added email-formatting draft post
All checks were successful
/ deploy (push) Successful in 26s
2025-07-13 18:25:30 -04:00
8fac51f4d0 upgrade toc in email-in-kakoune to new system 2025-07-13 18:14:28 -04:00
307f27f3fc ci: bumped zona ref 2025-07-13 18:14:05 -04:00
5611df962e updated css 2025-07-13 16:03:58 -04:00
301210fde8 update readme 2025-07-13 15:28:08 -04:00
b0144d06b4 updated justfile to reference new ci workflow 2025-07-13 15:26:48 -04:00
6 changed files with 423 additions and 202 deletions

View file

@ -8,7 +8,7 @@ jobs:
container: container:
image: node:alpine image: node:alpine
env: env:
ZONA: git+https://git.ficd.sh/ficd/zona.git@dacea2756af75d1151788cc0c1b2eefbead3c01f ZONA: git+https://git.ficd.sh/ficd/zona.git@10d1772a2d7a14c977e8359e3df25a2a40948daa
site: ficd.sh site: ficd.sh
site_draft: draft.ficd.sh site_draft: draft.ficd.sh
steps: steps:

View file

@ -2,7 +2,7 @@
This is the source code for my personal website, [ficd.sh], built with the This is the source code for my personal website, [ficd.sh], built with the
[zona] static site generator. It's hosted on [srht.site] and automatically [zona] static site generator. It's hosted on [srht.site] and automatically
deployed via [builds.sr.ht] when changes are pushed to the `main` branch deployed via Forgejo actions when changes are pushed to the `main` branch
of this repository. I use `zona serve` to preview locally before pushing of this repository. I use `zona serve` to preview locally before pushing
changes. changes.
@ -10,9 +10,9 @@ changes.
2. The output is packaged into `.tar.gz` format. 2. The output is packaged into `.tar.gz` format.
3. The tarball is published with `hut pages publish`. 3. The tarball is published with `hut pages publish`.
You can see the entire pipeline in [`.build.yml`](./.build.yml). You can see the entire pipeline in
[here](./.forgejo/workflows/deploy.yml).
[zona]: https://git.ficd.sh/ficd/zona [zona]: https://git.ficd.sh/ficd/zona
[ficd.sh]: https://ficd.sh [ficd.sh]: https://ficd.sh
[srht.site]: https://srht.site [srht.site]: https://srht.site
[builds.sr.ht]: https://builds.sr.ht

View file

@ -0,0 +1,189 @@
---
title: Email Formatting Is Harder Than It Looks
date: 2025-07-13
draft: true
---
*[UTF-8]: Unicode Transformation Format - 8 bit.
[Kakoune]: https://kakoune.org
As I've [mentioned before](./email-in-kakoune.md), I like using [Kakoune] for
reading & writing emails. Of course, Kakoune is a text editor, not a _rich text_
editor. It operates on UTF-8 _plaintext_ --- which means that the emails I write
need to be in plaintext, too.
As I went down this path, I quickly discovered that I needed an **email
formatter**. I eventually wrote [`mailfmt`](https://git.sr.ht/~ficd/mailfmt) to
fill this niche. It provides consistent paragraph spacing, hard-wrapping and
paragraph reflow, while preserving Markdown syntax, email headers, quotes,
sign-offs, and signature blocks. Additionally, the wrapped output can be made
safe for passing to a Markdown parser. This is useful if you want to build an
HTML email from plain-text.
`mailfmt` open-source under the ISC license, and is available on
[PyPI](https://pypi.org/project/mailfmt/) for installation with tools like
`pipx` and `uv`. The source code is available on sourcehut at
[git.ficd.sh/ficd/mailfmt](https://git.ficd.sh/ficd/mailfmt).
## Target Audience
I wrote this tool primarily for myself. It's served me very well over the past
few months. `mailfmt` could be helpful for anyone that prefers writing email in
plain-text using text editors like Kakoune, Helix, and Vim. It can format via
`stdin`/`stdout` and read/write files, making `mailfmt` easy to configure as a
formatter for the `mail` filetype in your editor.
I'm including a very lengthy explanation of exactly why I built this tool. You
may think it's overkill for such a small program — but I like to be crystal
clear about justifying my work. It reads like blog post rather than the
emoji-filled `README`/marketing style we're accustomed to seeing on this
platform. I've put a lot of thought into this, and I want to share my work. I
hope you enjoy reading about my thought process.
## Why I Built It (Comparison)
Unsurprisingly, it all started with a specific problem I was having composing
emails in plain-text format in my preferred text editor. As I searched for a
solution, I couldn't find anything that met all my needs, so I wrote it myself.
Here's what I wanted:
- A way to consistently format my outgoing emails in my text editor.
- Paragraph reflow and automatic line wrapping.
- Not all plain-text clients are capable of line-wrap. In some contexts, such
as mailing lists, the author is expected to wrap the text themselves.
- Inline Markdown syntax `can _still_ look great, **even** in plain-text!` Thus,
I wanted to use it:
- Without it being broken by reflow & wrap.
- While looking good and retaining the same semantics in _both_ rendered
**and** plain-text form — ideal for `multipart` emails.
- Ensure signature block is formatted properly.
- The single space after `--` and before the newline **must** be included.
### `fmt` and Markdown Formatters Don't Work For Email
The `fmt` utility provides great wrapping and reflow capabilities — I use it all
the time while writing LaTeX. However, it's syntax agnostic, and breaks
Markdown. For example, it completely mangles fenced code blocks. I figured: hey,
why not just use a Markdown formatter? It supports Markdown (obviously), _and_
can reflow & wrap text! Here's the problem: it turns out treating your
**entire** email as a Markdown document isn't ideal.
`mailfmt`'s approach is simple: detect when a line matches a known pattern of
Markdown block element syntax, such as leading `#` for headings, `-` for lists,
etc. If so, **leave the line untouched**. Similarly, **don't format anything
inside fenced code blocks**.
#### Sign-Offs
Consider the following sign-off:
```
Best wishes,
Daniel
```
A Markdown formatter considers this to be one paragraph, and reflows it
accordingly, causing it to lost semantic meaning:
```
Best wishes, Daniel
```
Within the confines of Markdown, I counted three ways of dealing with the
problem:
1. Put an empty line between the two parts:
```
Best wishes,
Daniel
```
> However, this empty line looks _awkward_ when viewed in plain-text.
2. Put a backslash after the intentional line break:
```
Best wishes, \
Daniel
```
> Again, this looks bad when the Markdown isn't rendered.
3. Put two spaces after the intentional line break (• = space):
```
Best•wishes,••
Daniel
```
> This syntax is **ambiguous, easy to forget**, and **not supported by editors
> that trim trailing whitespace.**
`mailfmt` detects sign-offs using a very simple heuristic. First, we check if a
line has _5 or less_ words, and **ends with a comma**. If we find such a line,
we check the _next_ line. If it has 5 or less words **that all begin with an
uppercase letter**, then we assume these two lines are a _sign-off_, and we
don't reflow or wrap them. The heuristic matches a very simple pattern:
```
A courteous greeting,
First Middle Last Name
```
#### Signature Block
The convention for signature blocks is as follows:
1. Begins with two `-` characters followed by a single space, then a newline.
2. Everything that follows until the EOF is part of the signature.
Here's an example (note the • = space):
```
--•
Daniel
Software•Developer,•Company
email@website.com
```
As with sign-offs, such a signature block gets mangled by Markdown formatters.
Furthermore, the single space after the `--` token is important: if it's
missing, some clients won't recognize it is a valid signature — our formatter
should address this too.
`mailfmt` detects when a line's _only_ content is `--`. It adds the required
trailing space if it's missing, and it treats the rest of the input as part of
the signature, leaving it completely untouched.
### Consistent Multipart Emails
Something you may want to do is generate a `multipart` email. This means that
_both_ an HTML **and** plain-text representation of the _same_ email are
included in the file — leaving it up to the reader's client to pick which one to
display.
The plain-text email **must** be able to stand on its own, and _also_ render to
decent-looking HTML. Essentially, you want to write your email in plain-text
once, ensuring it has proper formatting, and then use a command to generate an
HTML email from it. For this, `mailfmt` provides the `--markdown-safe` flag,
which appends backslashes to the formatted output, making it safe for Markdown
parsing without messing up the line breaks after sign-offs and signature blocks.
For example, I use the following in [aerc](https://aerc-mail.org/) to generate
an HTML multipart email whenever I want:
```ini
[multipart-converters]
text/html=mailfmt --markdown-safe | pandoc -f markdown -t html --standalone
```
## Conclusion
If you've made it this far, thanks for sticking with me and reading to the end!
Even if you don't plan to write plain-text email or use `mailfmt` at all, I hope
you learned something interesting.

View file

@ -3,24 +3,18 @@ title: Writing Emails In Kakoune
date: 2025-06-01 date: 2025-06-01
--- ---
This post will guide you through my setup for using Kakoune as an email composer This post will guide you through my setup for using Kakoune as an email
inside `aerc`. I'll also explain how to configure Kakoune to act as the _pager_ composer inside `aerc`. I'll also explain how to configure Kakoune to act
for reading `text/plain` emails. If you only care about the final config, feel as the _pager_ for reading `text/plain` emails. If you only care about the
free to skip to it [here](#final-configuration). final config, feel free to skip to it [here](#final-configuration).
<!--toc:start--> [TOC]
- [Naive Approach](#naive-approach)
- [Composer Setup](#composer-setup)
- [Reader Setup](#reader-setup)
- [Final Configuration](#final-configuration)
<!--toc:end-->
## Naive Approach ## Naive Approach
Since `aerc` uses your `$EDITOR` for composition, you don't technically have to Since `aerc` uses your `$EDITOR` for composition, you don't technically
do anything. I prefer setting it explicitly in `aerc.conf`, for good measure: have to do anything. I prefer setting it explicitly in `aerc.conf`, for
good measure:
```ini ```ini
[compose] [compose]
@ -31,8 +25,9 @@ The rest of the magic happens in your `kakrc`.
## Composer Setup ## Composer Setup
Essentially, we want to hook `filetype=mail` and set our buffer configuration Essentially, we want to hook `filetype=mail` and set our buffer
there. I'll share a recommended configuration with some explanation. configuration there. I'll share a recommended configuration with some
explanation.
```kak ```kak
hook global WinSetOption filetype=mail %~ hook global WinSetOption filetype=mail %~
@ -47,51 +42,53 @@ hook global WinSetOption filetype=mail %~
~ ~
``` ```
I use a custom formatter to format emails. It automatically hard-wraps lines I use a custom formatter to format emails. It automatically hard-wraps
while preserving certain markup elements, code blocks, sign-offs, and signature lines while preserving certain markup elements, code blocks, sign-offs,
blocks. For more details, check the formatting section of my post on and signature blocks. For more details, check the formatting section of my
[Helix](/blog/email/helix#formatting). post on [Helix](/blog/email/helix#formatting).
I find that setting `>` as the `comment_line` token is convenient for working I find that setting `>` as the `comment_line` token is convenient for
with quotes in replies. working with quotes in replies.
The `try autospell-enable` enables my The `try autospell-enable` enables my
[kak-autospell](https://codeberg.org/ficd/kak-autospell) plugin for the buffer. [kak-autospell](https://codeberg.org/ficd/kak-autospell) plugin for the
Essentially, it provides spellchecking that's continuously refreshed and hidden buffer. Essentially, it provides spellchecking that's continuously
in insert mode. refreshed and hidden in insert mode.
The remaining commands configure auto-formatting on save. I always prefer having The remaining commands configure auto-formatting on save. I always prefer
this on so I never forget to format my message before sending it. having this on so I never forget to format my message before sending it.
## Reader Setup ## Reader Setup
I find that using Kakoune to **read** emails is helpful because of how easy it I find that using Kakoune to **read** emails is helpful because of how
is to copy quotes, open links, etc. Configuring this is a tad hackier, however. easy it is to copy quotes, open links, etc. Configuring this is a tad
The basic idea is to set Kakoune as the viewer `pager` in `aerc.conf`. hackier, however. The basic idea is to set Kakoune as the viewer `pager`
in `aerc.conf`.
However, all this does is pipe the email to `kak` through standard input, so we However, all this does is pipe the email to `kak` through standard input,
need to tell the editor to treat it like an email: so we need to tell the editor to treat it like an email:
```ini ```ini
[viewer] [viewer]
pager=kak -e 'set buffer filetype mail' pager=kak -e 'set buffer filetype mail'
``` ```
When you're using Kakoune as a pager, you'll probably want to configure some When you're using Kakoune as a pager, you'll probably want to configure
things differently. In my case, I like to set the buffer as `readonly`, remove some things differently. In my case, I like to set the buffer as
the `number-lines` and `show-whitespaces` highlighters, disable soft-wrap & my `readonly`, remove the `number-lines` and `show-whitespaces` highlighters,
scrolloff settings, and _not_ set any formatters. disable soft-wrap & my scrolloff settings, and _not_ set any formatters.
The `pager` command above sets the filetype, but we need to distinguish between The `pager` command above sets the filetype, but we need to distinguish
_composing_ and _reading_ in our Kakoune hook. When Kakoune is opened with input between _composing_ and _reading_ in our Kakoune hook. When Kakoune is
through standard input, it loads a buffer that's conveniently named `*stdin*`. opened with input through standard input, it loads a buffer that's
Thus, we can check the buffer name before continuing. conveniently named `*stdin*`. Thus, we can check the buffer name before
continuing.
If we're in "reading mode", we define a hidden command called `ismailreader` If we're in "reading mode", we define a hidden command called
which doesn't do anything. Why? If the command is defined, and we try to invoke `ismailreader` which doesn't do anything. Why? If the command is defined,
it... well, nothing happens! But if it's **not** defined, we get an error and we try to invoke it... well, nothing happens! But if it's **not**
instead. We can combine this with the `try` command to for some simple boolean defined, we get an error instead. We can combine this with the `try`
logic. command to for some simple boolean logic.
```kak ```kak
evaluate-commands %sh{ evaluate-commands %sh{

View file

@ -98,7 +98,7 @@ h4 {
font-weight: bold; font-weight: bold;
} }
article h1+h4:first-of-type { article h1 + h4:first-of-type {
margin-block-start: 0rem; margin-block-start: 0rem;
} }
@ -128,6 +128,7 @@ li::marker {
a { a {
color: var(--main-link-color); color: var(--main-link-color);
text-decoration: underline;
} }
a:hover { a:hover {
@ -170,7 +171,14 @@ pre {
white-space: pre; white-space: pre;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
font-family: monospace; font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.95em; font-size: 0.95em;
} }
@ -186,10 +194,19 @@ pre {
/* Inline code styling */ /* Inline code styling */
:not(pre) > code { :not(pre) > code {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 3px;
background-color: #1d1d1d;
color: #d5d5d5;
font-size: 0.85em; font-size: 0.85em;
line-height: 1;
background-color: #1d1d1d;
border-radius: 6px;
vertical-align: middle;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
} }
/* Block code styling (inherits from pre) */ /* Block code styling (inherits from pre) */
@ -323,7 +340,14 @@ tbody tr:hover {
} }
table code { table code {
font-family: monospace; font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.85em; font-size: 0.85em;
background: #1d1d1d; background: #1d1d1d;
padding: 0.1em 0.25em; padding: 0.1em 0.25em;
@ -336,4 +360,15 @@ caption {
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
a > code {
text-decoration: none;
color: inherit;
}
a:has(> code) {
text-decoration: none;
}
a:hover > code {
background-color: var(--main-transparent);
}

View file

@ -1,4 +1,4 @@
zonaref := `rg "^.*(git\+.*).*$" -r '$1' .build.yml` zonaref := `rg "^.*(git\+.*).*$" -r '$1' .forgejo/workflows/deploy.yml`
echo: echo:
echo {{zonaref}} echo {{zonaref}}