Skip to main content

Inkletter

GitHub Repo CI PyPI version License: MIT

Inkletter — write your newsletter in Markdown, get a responsive themeable HTML email

Write your emails like prose, send them like a pro.

Inkletter turns plain Markdown files into beautiful, responsive MJML and HTML email layouts, ready to be previewed, shared or sent to the world.

Why Inkletter?

Because writing HTML emails by hand is like ironing socks: pointless and painful.

With Inkletter, you write your content in Markdown (like a decent human being), and it becomes a gorgeous, mobile-friendly HTML email powered by MJML.

Features

  • Markdown to MJML or to final responsive HTML, in one command
  • Layout from plain Markdown structure: side-by-side image rows, image-beside-text media objects, and call-to-action buttons from a lone bold link
  • Seven built-in themes, or your own theme in a small TOML file
  • Image sizing with Pandoc's {width=96px} attributes
  • Drops into a Django app: render the Markdown, then convert it
  • Live preview in your browser, with a device simulator (iPhone, iPad, desktop)
  • Clean Python API if you'd rather script it
  • Runs entirely on your machine, no account, no vendor lock-in

Installation

Python 3.10+ required.

pip install inkletter

See the changelog for what each version changed.

Or for development:

git clone https://github.com/lpauloin/Inkletter.git
cd Inkletter
pip install -e .

Usage

Preview a Markdown file as a responsive email

inkletter preview newsletter.md

Opens a split view in your browser: your Markdown, the generated MJML, and the rendered email in a device simulator.

Convert to HTML

inkletter md2html newsletter.md -o newsletter.html --view

Writes the final email HTML, and opens it in your browser with --view.

Export the raw MJML

inkletter md2mjml newsletter.md -o newsletter.mjml

Without -o, the MJML is printed to stdout, ready to be piped anywhere.

Plain-text version

inkletter md2txt newsletter.md -o newsletter.txt

The plain-text alternative for multipart/alternative sending — better deliverability, and a readable email everywhere. Headings are underlined, links become label <url>, buttons become → label : url call-to-action lines, and tables are ASCII-aligned.

Django templates

Building emails for a Django app? Let Django resolve the template while the document is still Markdown, then convert what comes out:

from django.template import Context
from django.template.loader import get_template

from inkletter import parse_markdown_to_html, parse_markdown_to_text

# autoescape off: this render produces Markdown, not HTML
markdown = get_template("emails/welcome.md").template.render(
    Context(context, autoescape=False)
)

html = parse_markdown_to_html(markdown)
text = parse_markdown_to_text(markdown)

In that order everything works with no special support: loops over table rows, filters with a | in a cell, conditionals around anything. The converter only ever sees plain Markdown, and the text part can align its table columns on the real values.

Values that are not yours need escaping — in a Markdown document, [Click here](https://evil.tld) is a working link, and a server response holding ``` escapes the code block you put it in. escape_markdown ships for the first; the second is textwrap.indent. Wiring them to template filters is three lines in your own app:

from inkletter import escape_markdown

register.filter("md", escape_markdown)
register.filter("md_code", lambda value: textwrap.indent(str(value), "    "))

See the Django integration guide for the setup, the full send function, and why this order.

Sizing an image

A logo exported at 2x arrives twice too large unless the document says how wide to draw it — and no theme can say it, because the theme does not know which image you inserted. Put the facts in braces, Pandoc's link_attributes syntax:

![Acme](logo@2x.png){width=96px}

![Screenshot](shot.png){width=50% align=left}

[![Acme](logo@2x.png)](https://acme.example){width=96px}

width, height and align — and nothing else. A dimension is a fact about the asset; an appearance is a choice of theme, so no CSS property is ever accepted here. Lengths take px or %, a bare number means pixels, and alignment is left, center or right.

A block only counts when it is glued to an image, or to a link wrapping one. A space before the brace keeps it as text, and so does anything that is not an attribute — {beta} or {see below} travel through untouched. Turn the whole thing off with --no-link-attributes.

Document title

When your Markdown opens with a plain-text # heading, it becomes the email's <title> — the tab of a "view in browser" page, and what a screen reader announces. A heading carrying emphasis, a link or an image is left alone rather than flattened, and the document simply has no title. This is not the subject line: that one you pass when sending.

Layout

Layout is driven by plain CommonMark structure — no custom syntax, the same file stays clean in any Markdown editor (and reusable for other channels):

  • A paragraph made only of images becomes a row of side-by-side columns (up to 4 on one row, more wrap into rows of 3):

    ![Left view](left.png) ![Right view](right.png)
    
  • A paragraph starting (or ending) with a single image beside text becomes a media object — image next to its text, 30/70 by default. Put the image last to place it on the right:

    ![Portrait](jean.png) Jean joined the team this week.
    He will own the rendering platform.
    
  • A paragraph made only of a bold link becomes a call-to-action button (a real mj-button, styled by the theme). A plain link stays a link, and bold links inside lists, tables or quotes stay bold links:

    **[Get started](https://example.com/go)**
    

    Pass --no-bold-link-button to keep bold links as links.

On mobile everything stacks gracefully, image on top. Ratios, spacing and colors are tuned in the [images] and [buttons] theme sections below — including text_layout = "stacked" to disable media-object columns entirely.

Theming

There is always a theme: without --theme, the default one applies. Every command accepts --theme with a preset name or a theme file:

inkletter md2html newsletter.md --theme dark
inkletter md2html newsletter.md --theme mytheme.toml

Built-in presets

Preset Mood
default Clean and neutral — Helvetica, gray text, blue links
dark Slate night mode — light text, Trebuchet MS headings
crystal Airy and elegant — Palatino headings, cold blue accents
blue Corporate and trustworthy — Tahoma text, Trebuchet MS headings
green Organic and editorial — Georgia throughout
red Bold and editorial — Georgia headings over Helvetica text
yellow Warm and friendly — Verdana text, Trebuchet MS headings

Each preset is rendered on desktop and on a 375px mobile screen in the theme gallery.

Write your own

A theme file is partial — set only what you want to change, everything else keeps the default look:

[layout]
width = "640px"

[text]
font_family = "Georgia, serif"

[links]
color = "#c0392b"
underline = false
Section Keys
[layout] width, background_color, content_background_color, section_padding
[text] font_family, font_size, line_height, color
[headings] font_family, color, font_weight, and one [headings.hN] subsection per level (size, align)
[links] color, underline
[code] font_family, background_color, color
[quote] color, border_color, font_style
[divider] color, width
[table] border_color, cell_padding, header_color, header_background_color
[images] align, row_gap, border_radius, text_layout, media_ratio
[buttons] background_color (inherits links.color), color, border_radius, font_weight, padding, align

Each heading level is its own subsection, so a centred headline over left-aligned subheadings — the shape most newsletters take — is two lines:

[headings.h1]
align = "center"

Only what you name changes: h1 keeps its default size, and h2 to h6 keep everything. Any unknown section or key fails loudly, with the list of valid ones.

Web fonts

A [fonts] section loads a font your readers may not have. Declare the name and a stylesheet URL, then use it in text.font_family:

[fonts]
Lora = "https://fonts.googleapis.com/css2?family=Lora"

[text]
font_family = "Lora, Georgia, serif"

The fallback is the main rendering, not a safety net. Web fonts load in Apple Mail, iOS Mail, Outlook for Mac and Thunderbird. Gmail, Outlook for Windows and most webmails ignore them and show the next font in the stack — so Lora, Georgia, serif has to look good without Lora.

MJML only loads a font that a component actually uses, and text.font_family is the only theme setting it reads. A font declared for the headings alone would never load, so Inkletter refuses that theme rather than letting it fail in silence. Without a [fonts] section, an Inkletter email makes no external request at all.

From Python

Same defaults, same presets, plus optional named color palettes:

from inkletter.colors import Blue
from inkletter.md_to_html import parse_markdown_to_html
from inkletter.theme import Links, Text, Theme

theme = Theme(text=Text(font_family="Georgia, serif"), links=Links(color=Blue.DARK))
html = parse_markdown_to_html(markdown, theme=theme)

URL shortening

Every URL of the document can go through a factory you define — the classic newsletter needs: shorteners, click tracking, UTM tags. A Bitly implementation ships with Inkletter:

from inkletter.md_to_html import parse_markdown_to_html
from inkletter.shortener import BitlyShortener

html = parse_markdown_to_html(markdown, url_factory=BitlyShortener(token="..."))

Or write your own: subclass URLFactory and override only what concerns you — rewrite_link for click URLs (links, image links, buttons), rewrite_image for image sources. A shortener that only overrides rewrite_link never touches images, by simple inheritance:

from inkletter.shortener import URLFactory


class UTMTagger(URLFactory):
    def rewrite_link(self, url):
        return f"{url}?utm_source=newsletter&utm_medium=email"

BitlyShortener shortens each distinct URL once (in-memory cache), and exceptions raised by a factory propagate untouched. Python API only — the CLI does not expose factories.

Samples

Contributing

French or not, you are welcome to contribute. Fork it, branch it, test it, PR it — with love.

pip install -r requirements-test.txt
pytest

Every push and pull request runs through the GitHub Actions CI on Python 3.10 to 3.13.

License

MIT — but don't forget to say "merci" 😉

Made with ❤️ and markdown in France.

Metadata

Release files for inkletter 2.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for inkletter 2.1.0
File Size Uploaded
inkletter-2.1.0.tar.gz 52.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for inkletter 2.1.0
File Interpreter ABI Platform
inkletter-2.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 95.4 kB

Release files / inkletter-2.1.0.tar.gz

Download URL inkletter-2.1.0.tar.gz
Size 52.0 kB
Tags Source
SHA-256 checksum
How to use checksums
4e3732d739b627d2574454939ab6899a17d6f1eda2eea388ca064a4c8f4d6c8f
BLAKE2b-256 checksum
How to use checksums
f4060c4bd079a769529dfcaf0580dde1e390b5975bfb8f10da0ef1f461c978d0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release files / inkletter-2.1.0-py3-none-any.whl

Download URL inkletter-2.1.0-py3-none-any.whl
Size 43.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
826e615611cea294ef7f3785b5cd32ce6cf6717004f07708994b73ce90f405f2
BLAKE2b-256 checksum
How to use checksums
35ecce3a082e668c7ceb6e49bf10e1d1853a1e121179875621984f53dd65312f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release history Release notifications | RSS feed

3.4.0

2 release files

3.3.0

2 release files

3.2.0

2 release files

3.1.0

2 release files

3.0.0

2 release files

2.2.0

2 release files

This release

2.1.0 This release

2 release files

2.0.0

2 release files

1.1.0

2 release files

1.0.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page