Compare commits

..

No commits in common. "1f413b099c7d5ead0589b92ce4ca377125a24caa" and "020a745a68f1f58aea10fb344b328f4188876236" have entirely different histories.

6 changed files with 202 additions and 423 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@10d1772a2d7a14c977e8359e3df25a2a40948daa ZONA: git+https://git.ficd.sh/ficd/zona.git@dacea2756af75d1151788cc0c1b2eefbead3c01f
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 Forgejo actions when changes are pushed to the `main` branch deployed via [builds.sr.ht] 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 You can see the entire pipeline in [`.build.yml`](./.build.yml).
[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

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

View file

@ -1,20 +1,20 @@
:root { :root {
--main-text-color: #b4b4b4; --main-text-color: #b4b4b4;
--main-bg-color: #121212; --main-bg-color: #121212;
--main-link-color: #df6464; --main-link-color: #df6464;
--main-heading-color: #df6464; --main-heading-color: #df6464;
--main-bullet-color: #d87c4a; --main-bullet-color: #d87c4a;
--main-transparent: rgba(255, 255, 255, 0.15); --main-transparent: rgba(255, 255, 255, 0.15);
--main-small-text-color: rgba(255, 255, 255, 0.45); --main-small-text-color: rgba(255, 255, 255, 0.45);
} }
body { body {
line-height: 1.6; line-height: 1.6;
font-size: 18px; font-size: 18px;
font-family: sans-serif; font-family: sans-serif;
background: var(--main-bg-color); background: var(--main-bg-color);
color: var(--main-text-color); color: var(--main-text-color);
padding-left: calc(100vw - 100%); padding-left: calc(100vw - 100%);
} }
.toclink { .toclink {
@ -63,312 +63,277 @@ h3,
h4, h4,
h5, h5,
h6 { h6 {
color: var(--main-heading-color); color: var(--main-heading-color);
} }
h1 { h1 {
margin-block-start: 0.67rem; margin-block-start: 0.67rem;
margin-block-end: 0.67rem; margin-block-end: 0.67rem;
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
} }
article h1:first-of-type { article h1:first-of-type {
margin-block-start: 1.67rem; margin-block-start: 1.67rem;
} }
h2 { h2 {
margin-block-start: 0.83rem; margin-block-start: 0.83rem;
margin-block-end: 0.83rem; margin-block-end: 0.83rem;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
} }
h3 { h3 {
margin-block-start: 1rem; margin-block-start: 1rem;
margin-block-end: 1rem; margin-block-end: 1rem;
font-size: 1.17em; font-size: 1.17em;
font-weight: bold; font-weight: bold;
} }
h4 { h4 {
margin-block-start: 1.33rem; margin-block-start: 1.33rem;
margin-block-end: 1.33rem; margin-block-end: 1.33rem;
font-size: 1rem; font-size: 1rem;
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;
} }
h5 { h5 {
margin-block-start: 1.67rem; margin-block-start: 1.67rem;
margin-block-end: 1.67rem; margin-block-end: 1.67rem;
font-size: 0.83rem; font-size: 0.83rem;
font-weight: bold; font-weight: bold;
} }
h6 { h6 {
margin-block-start: 2.33rem; margin-block-start: 2.33rem;
margin-block-end: 2.33rem; margin-block-end: 2.33rem;
font-size: 0.67rem; font-size: 0.67rem;
font-weight: bold; font-weight: bold;
} }
ul { ul {
list-style-type: disc; list-style-type: disc;
/* or any other list style */ /* or any other list style */
} }
li::marker { li::marker {
color: var(--main-bullet-color); color: var(--main-bullet-color);
/* Change this to your desired color */ /* Change this to your desired color */
} }
a { a {
color: var(--main-link-color); color: var(--main-link-color);
text-decoration: underline;
} }
a:hover { a:hover {
background: var(--main-transparent); background: var(--main-transparent);
} }
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
img { img {
display: block; display: block;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
width: auto; width: auto;
height: auto; height: auto;
} }
blockquote { blockquote {
color: var(--main-small-text-color); color: var(--main-small-text-color);
border-left: 3px solid var(--main-transparent); border-left: 3px solid var(--main-transparent);
padding: 0 1rem; padding: 0 1rem;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }
hr { hr {
border: none; border: none;
height: 1px; height: 1px;
background: var(--main-small-text-color); background: var(--main-small-text-color);
} }
code { code {
background: var(--main-transparent); background: var(--main-transparent);
border-radius: 0.1875rem; border-radius: 0.1875rem;
/* padding: .0625rem .1875rem; */ /* padding: .0625rem .1875rem; */
/* margin: 0 .1875rem; */ /* margin: 0 .1875rem; */
} }
code, code,
pre { 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: font-family: monospace;
ui-monospace, font-size: 0.95em;
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 0.95em;
} }
pre { pre {
background-color: #151515; background-color: #151515;
color: #d5d5d5; color: #d5d5d5;
padding: 1em; padding: 1em;
border-radius: 5px; border-radius: 5px;
line-height: 1.5; line-height: 1.5;
overflow-x: auto; overflow-x: auto;
} }
/* Inline code styling */ /* Inline code styling */
:not(pre) > code { :not(pre) > code {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
font-size: 0.85em; border-radius: 3px;
line-height: 1; background-color: #1d1d1d;
background-color: #1d1d1d; color: #d5d5d5;
border-radius: 6px; font-size: 0.85em;
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) */
pre code { pre code {
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: none; background: none;
} }
small { small {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
small a { small a {
color: inherit; color: inherit;
/* Inherit the color of the surrounding <small> text */ /* Inherit the color of the surrounding <small> text */
text-decoration: underline; text-decoration: underline;
/* Optional: Keep the underline to indicate a link */ /* Optional: Keep the underline to indicate a link */
} }
.title-container { .title-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
.title-container h1 { .title-container h1 {
margin: 0; margin: 0;
} }
.image-container { .image-container {
text-align: center; text-align: center;
margin: 20px 0; margin: 20px 0;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
/* Optional: add some spacing around the image container */ /* Optional: add some spacing around the image container */
} }
.image-container img { .image-container img {
max-width: 100%; max-width: 100%;
width: auto; width: auto;
max-height: 100%; max-height: 100%;
height: auto; height: auto;
} }
.fixed .image-container img { .fixed .image-container img {
max-width: 308px; max-width: 308px;
max-height: 308px; max-height: 308px;
} }
.image-container small { .image-container small {
display: block; display: block;
/* Ensure the caption is on a new line */ /* Ensure the caption is on a new line */
margin-top: 5px; margin-top: 5px;
/* Optional: adjust spacing between image and caption */ /* Optional: adjust spacing between image and caption */
} }
.image-container small a { .image-container small a {
color: inherit; color: inherit;
/* Ensure the link color matches the small text */ /* Ensure the link color matches the small text */
text-decoration: underline; text-decoration: underline;
/* Optional: underline to indicate a link */ /* Optional: underline to indicate a link */
} }
#header ul { #header ul {
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
} }
#header li { #header li {
display: inline; display: inline;
font-size: 1.2rem; font-size: 1.2rem;
margin-right: 1.2rem; margin-right: 1.2rem;
} }
#container { #container {
margin: 2.5rem auto; margin: 2.5rem auto;
width: 90%; width: 90%;
max-width: 60ch; max-width: 60ch;
} }
#postlistdiv ul { #postlistdiv ul {
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
} }
.moreposts { .moreposts {
font-size: 0.95rem; font-size: 0.95rem;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
#nextprev { #nextprev {
text-align: center; text-align: center;
margin-top: 1.4rem; margin-top: 1.4rem;
font-size: 0.95rem; font-size: 0.95rem;
} }
#footer { #footer {
color: var(--main-small-text-color); color: var(--main-small-text-color);
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
margin: 1.5rem auto; margin: 1.5rem auto;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; /* Use center if you prefer */ text-align: left; /* Use center if you prefer */
} }
th, td { th, td {
border: 1px solid var(--main-transparent); border: 1px solid var(--main-transparent);
/*border: 1px solid var(--main-bullet-color);*/ /*border: 1px solid var(--main-bullet-color);*/
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
vertical-align: middle; vertical-align: middle;
} }
thead th { thead th {
font-weight: bold; font-weight: bold;
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
color: var(--main-text-color); color: var(--main-text-color);
} }
tbody tr:nth-child(even) { tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02); background-color: rgba(255, 255, 255, 0.02);
} }
tbody tr:hover { tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.05);
} }
table code { table code {
font-family: font-family: monospace;
ui-monospace, font-size: 0.85em;
SFMono-Regular, background: #1d1d1d;
SF Mono, padding: 0.1em 0.25em;
Menlo, border-radius: 3px;
Consolas,
Liberation Mono,
monospace;
font-size: 0.85em;
background: #1d1d1d;
padding: 0.1em 0.25em;
border-radius: 3px;
} }
caption { caption {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.8rem; font-size: 0.8rem;
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' .forgejo/workflows/deploy.yml` zonaref := `rg "^.*(git\+.*).*$" -r '$1' .build.yml`
echo: echo:
echo {{zonaref}} echo {{zonaref}}