# hemmer
A Rust pipeline for transforming HTML into email-client-ready output.
`hemmer` takes HTML — optionally with Tailwind utility classes — and runs it
through a configurable set of transformations that paper over the quirks of
email clients: CSS inlining, table attribute defaults, Outlook conditional
comments, `rem` → `px` conversion, CSS variable resolution, and a long list
of smaller fixes.
## Status
Early but functional. 151 tests passing. The API will probably keep shifting
until it stabilizes around a `0.x` release.
## Origin
`hemmer` was built to enable HTML + Tailwind email templates as a
replacement for [MJML](https://mjml.io) in an Elixir/Phoenix app. MJML
makes it hard to picture what you'll end up with until it's compiled, and
LLM-based assistants struggle with its custom markup — both humans and
models are far more comfortable in plain HTML and Tailwind.
[Maizzle](https://maizzle.com) was the original inspiration for the
transformer pipeline, but it brings a Node.js dependency and a templating
layer we didn't need (HEEx already handles that), and we wanted runtime
processing instead of a build step. Building this in Rust gave us all of
that and a clean Elixir NIF integration via
[Rustler](https://github.com/rusterlium/rustler).
The name *hemmer* is a sewing term — a machine attachment that folds the
edge of fabric to create a clean hem. Fitting for a tool that takes raw
HTML and gives it a clean edge for the limitations of email clients.
## What it does
`hemmer` runs a pipeline of independent transformers. Most are enabled by
default; a few are opt-in. They're applied in a fixed order optimized for
email output.
### CSS generation and inlining
- **`tailwind`** — runtime Tailwind CSS generation via
[encre-css](https://gitlab.com/encre-org/encre-css), with email-safe hex
color overrides for all 22 Tailwind v3 color scales (no `oklch()`)
- **`safe_class_names`** — rewrites Tailwind escaped characters
(`\:`, `\/`, `[`, `]`, `%`, `#`, …) to email-safe equivalents in both
`class` attributes and `<style>` tag contents
- **`inline_css`** — moves `<style>` rules into element `style` attributes
via [css-inline](https://github.com/Stranger6667/css-inline)
- **`class_cleanup`** — removes inlined classes from elements while
preserving classes referenced by `@media` queries
- **`purge_css`** — removes unused rules from `<style>` blocks (opt-in;
the Tailwind generator already produces only used CSS)
### Email-client compatibility
- **`email_compat_css`** — converts `rem` → `px` and CSS logical properties
(`padding-inline`, `margin-block`, …) to physical equivalents. Outlook,
Gmail, and Yahoo support neither.
- **`resolve_props`** — replaces `var(--name)` references with the static
values from `:root` declarations. Outlook desktop doesn't support custom
properties.
- **`resolve_calc`** — evaluates `calc()` expressions with same-unit
arithmetic to constants. Outlook desktop doesn't support `calc()`.
- **`style_to_attr`** — copies CSS `width` / `height` / `bgcolor` / `align`
values into HTML attributes. Outlook desktop's Word renderer often honors
HTML attributes when it ignores CSS.
- **`attribute_to_style`** — the opposite direction: copies HTML
presentational attributes into inline CSS for modern clients (opt-in).
### HTML defaults and cleanup
- **`html_transforms`** — adds `cellpadding="0" cellspacing="0" role="none"`
to tables, ensures `<img>` has an `alt` attribute, expands 3-digit hex
colors in `bgcolor` and `color` attributes
- **`outlook_tags`** — `<outlook>` and `<not-outlook>` tags become MSO
conditional comments. Supports version names (`only="2013"` →
`[if mso 15]`) and the full `only` / `not` / `lt` / `lte` / `gt` / `gte`
attribute set.
- **`widows`** — inserts ` ` between the last two words in elements
marked with `prevent-widows` or `no-widows`
- **`base_url`** — resolves relative URLs in HTML attributes (`src`, `href`,
`srcset`, …) and CSS `url()` values inside both inline styles and
`<style>` tags
- **`url_params`** — appends UTM/tracking parameters to absolute URLs in
`<a>` tags
- **`meta_tags`** — auto-injects DOCTYPE, charset, viewport, and
format-detection meta tags if missing
- **`remove_attributes`** — configurable removal with empty / always-remove
/ exact-value / regex-match rules
- **`minify`** — final HTML minification (opt-in)
## What it doesn't do
`hemmer` is **not** a templating engine. Bring your own — HEEx, Tera,
MiniJinja, plain string formatting, anything. The pipeline runs *after*
templating, on a complete HTML string.
It also doesn't:
- generate plaintext versions of emails (do this in the layer above)
- send the email (use Swoosh, lettre, etc.)
- convert MJML markup (it's an alternative to MJML, not a converter)
## Usage
```rust
use hemmer::Pipeline;
let html = r#"
<html>
<head></head>
<body>
<table>
<tr>
<td class="p-6 bg-indigo-600 text-white text-center">
<h1 class="text-xl font-bold">Welcome!</h1>
</td>
</tr>
</table>
</body>
</html>
"#;
let result = Pipeline::with_tailwind().process(html)?;
println!("{}", result.html);
```
This generates Tailwind CSS for the classes used in the document, inlines
it, applies all the email-compatibility transforms, and injects DOCTYPE and
meta tags.
For a minimal pipeline that only does what you opt into:
```rust
use hemmer::{Pipeline, InlineCssConfig};
let result = Pipeline::minimal()
.inline_css(InlineCssConfig::default())
.minify(true)
.process(html)?;
```
The full builder API lets you toggle every transformer individually. See
[`src/pipeline.rs`](src/pipeline.rs) for the available methods.
## Using from Elixir
`hemmer` is designed to be wrapped as a
[Rustler](https://github.com/rusterlium/rustler) NIF for Elixir
applications. There's no first-party Elixir package yet — wire it up in your
project's `native/` directory with a thin Rust wrapper:
```toml
# native/email_nif/Cargo.toml
[package]
name = "email_nif"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
rustler = "0.36"
hemmer = "0.1" # or path = "..." for local development
```
```rust
// native/email_nif/src/lib.rs
#[rustler::nif(schedule = "DirtyCpu")]
fn process(html: &str) -> Result<String, String> {
hemmer::Pipeline::with_tailwind()
.process(html)
.map(|r| r.html)
.map_err(|e| e.to_string())
}
rustler::init!("Elixir.YourApp.EmailTransformer");
```
```elixir
# lib/your_app/email_transformer.ex
defmodule YourApp.EmailTransformer do
use Rustler, otp_app: :your_app, crate: "email_nif"
def process(_html), do: :erlang.nif_error(:nif_not_loaded)
end
```
## Inspired by
- **[Maizzle](https://maizzle.com)** — for the transformer-pipeline
architecture and a long list of email-client gotchas to work around.
Several transformers in `hemmer` mirror Maizzle's defaults closely.
- **[encre-css](https://gitlab.com/encre-org/encre-css)** — runtime Tailwind
CSS generation in Rust, which makes the whole pipeline possible without a
Node-based build step.
- **[css-inline](https://github.com/Stranger6667/css-inline)** — the actual
CSS inlining engine, built on Servo's CSS parser.
- **[lol_html](https://github.com/cloudflare/lol-html)** — Cloudflare's
streaming HTML rewriter, used everywhere `hemmer` needs to manipulate the
DOM.
## License
MIT