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
  • The same Markdown rendered as a LinkedIn post — mentions, short-link aware counting, and a refusal rather than a truncation
  • 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
uv sync

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.

LinkedIn posts

The same Markdown, rendered as a post — a feed has no rich formatting, so a post is text, and everything else is what a feed does differently:

from inkletter import parse_markdown_to_linkedin

text = parse_markdown_to_linkedin(markdown, url_factory=my_shortener)

One document in, one string out. Nothing is invented on top of CommonMark:

In the Markdown In the post
a link label : https://… — no angle brackets
a bare address itself, once: it is a link like any other —
never ending on *, _ or ~, so bold
around it stays bold; a final ) is its own
only when it closes one inside
a domain without scheme a link to https:// plus what was written:
www. and any ending, or a name ending on a
listed extension (.com, .fr, .io, …),
written in lower case — readme.md, a mail
address and fini.De retour stay text
# heading its text on its own line, no underline
- item • item, since list markup is refused
1. item its number, as written
a bold link on its own label : url — a feed draws no button; the
factory is told it was bold
a table one line per row, cells joined by —
--- a blank line: a feed draws no rule
**bold** *italic* the words alone (see below)
#tag as written, never in look-alikes
an image left exactly as written — not supported

Three choices, so you know what to expect

A mention is a link whose target is the URN, which is the only thing that identifies an entity — a name alone mentions nobody:

Merci à [Acme](urn:li:organization:12345678) !

It goes out as @[urn:li:organization:12345678|Acme], the form a publishing API resolves into a real, blue, clickable mention. That is the single form Inkletter emits: LinkedIn's own read-back notation @[Name](urn:…) is not it — written into a post it goes inert and makes the rest of the text escape itself. A name is passed through untouched, because the match is exact and case-sensitive. A name containing | or ] cannot be expressed — they delimit the marker and nothing escapes them — so that one goes out as @Name, plain text.

Formatting is removed, not faked — unless you ask. The platform has no bold, italic or strikethrough, so by default the words go out plain. unicode_styling=True substitutes mathematical look-alikes instead. They are text, not formatting: a screen reader spells them out letter by letter, the platform's search does not match them, and each letter costs two characters rather than one. Hence opt-in. Bold and italic together take the bold italic look-alikes (***mot*** is 𝙢𝙤𝙩, whichever style is outside), and strikethrough adds its stroke on top. A mention or an address inside a styled run is never substituted, or it would stop resolving; neither is a hashtag, which in look-alikes would be another tag, nor the label of a bold link on its own — a call to action is a line of text — nor an emoji, which a stroke would break.

A hashtag is read as one, before the marks inside it. # at the start of a word, then letters, digits or underscores with a letter among them: #2026 stays a number, C# a language, #équipe_rh one tag rather than a name with an italic in it — and # heading, space and all, is a heading as it always was. Every output writes it as written; an email may colour it ([hashtags] in the theme).

Length is counted the way the platform counts it, in UTF-16 code units — an emoji costs 2, a skin tone 4, a Unicode bold letter 2 — on a text whose accents are composed first (the platform counts a decomposed é as two and shows one), declared fragment by fragment as the document is written rather than measured on the finished string: only the render knows that a mention weighs the name it displays instead of the marker carrying it. A URL factory that sets link_length prices every link at that width instead of at the address written in the document — your shortener produces links of one fixed size, and they do not exist yet when the text is counted.

Over the ceiling, nothing is truncated — parse_markdown_to_linkedin raises LengthError, which carries the length it counted and the limit, since a post cut mid-sentence without warning is worse than one that refuses to leave. The default is 4000, the transport limit; pass your own max_length=, or max_length=None to never refuse.

A first comment is a second document

Write it in its own file, render it with a second call, and publish it where it belongs. Inkletter has no notion of a first comment: it renders one document into one text, and a --- in your source stays a horizontal rule rather than quietly deciding what gets published.

comment = parse_markdown_to_linkedin(footnotes, max_length=1250)

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=320px 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 are in px, a bare number means pixels, and alignment is left, center or right.

A width holds on a phone too — an image without one follows its column either way, so nothing is made responsive by ignoring it.

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
[hashtags] color — unset, a #tag is text like the words around it
[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. rewrite_link also receives what the document says about the link: is_button for the one that is a button — a lone bold link, the call to action — and is_bold for a link inside bold text; a shortener that does not care ignores both. 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, is_button=False, is_bold=False):
        medium = "cta" if is_button else "email"
        return f"{url}?utm_source=newsletter&utm_medium={medium}"

A factory whose links are all the same width can say so, and the LinkedIn output will count every link at that width rather than at the address the document carries:

class MyShortener(URLFactory):
    link_length = len("https://exa.mp/r/abc123")

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.

A factory is only ever handed an address: a target that names something rather than locating it — an entity URN, a mailto:, a tel: — never reaches it, because rewriting one destroys it. A path with no scheme at all is an address like any other, so a local image still goes through.

Samples

Contributing

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

uv sync
uv run ruff check .
uv run ruff format --check .
uv run pytest

Those are the three the CI runs, in that order, on Python 3.10 to 3.13 — every push and every pull request. Run them as written rather than an equivalent: a command of your own has a different scope.

License

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

Made with ❤️ and markdown in France.

Metadata

Release files for inkletter 3.4.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 3.4.0
File Size Uploaded
inkletter-3.4.0.tar.gz 73.5 kB Details

Built distribution (wheel)

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

Total release size: 136.6 kB

Release files / inkletter-3.4.0.tar.gz

Download URL inkletter-3.4.0.tar.gz
Size 73.5 kB
Tags Source
SHA-256 checksum
How to use checksums
097606d199f89a6ead5a85ee551bd5ded69ba5ba0ad344e38a244709a52290c5
BLAKE2b-256 checksum
How to use checksums
d19f30af3802af05f902758ab0a0cf210d2b6e21b3ab1861cd05c4e0c2cfd45f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

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

Download URL inkletter-3.4.0-py3-none-any.whl
Size 63.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
03a016aee766e7d65c5a4fdaca2e163ed16f27c548c9f64861edfa0f23d08817
BLAKE2b-256 checksum
How to use checksums
7ba8d862595aee846ce80e412a1a4b1ac4242cd13ea0debb979fec7d4530113e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

3.4.0 This release

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

2.1.0

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