Skip to main content

Inkletter

GitHub Repo CI PyPI version npm 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 — and the same Markdown into a LinkedIn post or an HTML fragment. In Python, and in JavaScript with the same output to the character.

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 — with what a preview needs, line by line
  • An HTML fragment, safe to set in a page that trusts nothing typed
  • CommonMark read by markdown-it, with GitHub's tables, strikethrough and task lists
  • A JavaScript twin on npm: post, fragment and MJML identical to the character, held to it by shared scenarios
  • 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, typed (py.typed)
  • Runs entirely on your machine, no account, no vendor lock-in

Installation

Python 3.10+ required.

pip install inkletter

In JavaScript (Node 20+, or any bundler):

npm 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,
read before the marks inside it — started and
ended where LinkedIn does: after a space, (
or ", never glued to a word or a «; never
ending on . ? ! nor on *, _, ~,
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 keeps its letters and its case, because the match is exact and case-sensitive; only the spaces at its ends go, and a line break inside it becomes a space, as every output lays a name on one line. An empty name writes nothing. 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 LinkedIn reads one, before the marks inside it — measured in its own composer. #, then letters, digits or underscores, not glued to an ASCII letter or digit, _, & or /: #équipe_rh is one tag rather than a name with an italic in it, Merci ##tag holds #tag, #2026 is a tag as on the feed, C# a language — 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)

What a preview reads

A preview needs more than the string. render_linkedin gives the post, what LinkedIn counts for it, its lines fragment by fragment — what each is (text, a mention, a hashtag, a link), what the feed shows of it (a mention's name rather than its marker, an address as written, its letters decoded) and what it costs — whether bold, italic or a heading were written that the feed will not show, and which styles were written at all (styles: what a warning names, look-alikes or not). It refuses nothing: the caller weighs length against its own ceiling.

from inkletter import render_linkedin

render = render_linkedin(markdown, url_factory=my_shortener)
render.text  # what goes out
render.length  # what LinkedIn counts
render.shown_text  # what the feed shows, on one string
render.lines[0].segments
# (Segment(kind="text", display="Merci à ", cost=8),
#  Segment(kind="mention", display="Acme", cost=4), …)
render.unrendered_formatting, render.unrendered_headings
render.styles  # frozenset({"bold"})

A target that runs something rather than locating it — javascript:, vbscript:, file:, data: but an image's — is never a link in any output: its words stand in. An editor asks the same question before it writes one (runs_something in Python, runsSomething in JavaScript).

An HTML fragment

The same document as the inside of a page rather than a whole email — a note set in a report, a comment shown on a screen:

from inkletter import parse_markdown_to_html_fragment

parse_markdown_to_html_fragment("A **bold** word\nand a line of its own.")
# '<p>A <strong>bold</strong> word<br>and a line of its own.</p>'

No theme applies: the page around it styles it. It is safe to set inside a page that trusts nothing the author typed — every text is escaped, raw HTML is dropped, a link that runs something (javascript:, vbscript:, file:, data: — its scheme read as a browser reads it, spaces and tabs left out) is written as its label, and an image is its alternative text, so that whatever renders the fragment never fetches anything for it.

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.

Document language

Pass the language your email is written in, and it lands on the <html> tag, where a screen reader picks its voice and a mail client its hyphenation:

html = parse_markdown_to_html(markdown, lang="en")

parse_markdown_to_mjml, wrap_mjml_document and wrap_mjml_body take the same argument, and so does the JavaScript package ({ lang: 'en' }). It goes on <mjml>, escaped like any attribute, and MJML carries it to <html>. Without it — or with an empty string — the root carries no language and the compiler decides: no attribute, or lang="und" (undetermined), depending on its version.

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

In JavaScript

The same package, output for output, lives in js/ and is published on npm as inkletter: the post, its count and its lines, the HTML fragment and the MJML come out of it character for character as they come out of Python — the same markdown-it rules, the same tree, the same visitors.

npm install inkletter
import { parseMarkdownToHtmlFragment, parseMarkdownToMjml, renderLinkedin, Theme } from 'inkletter';

const { text, length, lines, shownText } = renderLinkedin('**Merci** à [Acme](urn:li:organization:12345678) !');
const fragment = parseMarkdownToHtmlFragment('Un *mot*.');
const mjml = parseMarkdownToMjml('# Bonjour', { theme: Theme.named('dark') });

A theme is built from the same mapping as in Python (Theme.fromDict, the keys of a theme file). The responsive HTML is compiled by MJML's own browser build, loaded only when asked for (await parseMarkdownToHtml(…), with mjml-browser and cheerio installed beside it): its HTML is MJML's, not MRML's, which Python compiles with — the MJML going in is the same.

What holds the two together. scripts/parity.py renders every document the Python suite renders, and those of parity/documents.json, and writes the answers down — parity/linkedin.json, parity/html_fragment.json, parity/mjml.json; js/test/parity.test.ts wants the same characters. Change an output in Python, rerun the script, and the JavaScript has to follow before the CI passes.

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
uv run mypy --strict tests/typing
uv run python scripts/parity.py --check
cd js && npm ci && npm run typecheck && npm test && npm run build

Those are what the CI runs, in that order, on Python 3.10 to 3.13 and on Node 22 — every push and every pull request. Run them as written rather than an equivalent: a command of your own has a different scope. An output changed on purpose: uv run python scripts/parity.py rewrites the scenarios, to be read in the diff like a contract change.

License

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

Made with ❤️ and markdown in France.

Metadata

Release files for inkletter 4.3.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 4.3.0
File Size Uploaded Provenance Actions
inkletter-4.3.0.tar.gz 88.0 kB Without provenance Details for inkletter-4.3.0.tar.gz

Built distribution (wheel)

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

Total release size: 166.9 kB

Release files / inkletter-4.3.0.tar.gz

Download URL inkletter-4.3.0.tar.gz
Size 88.0 kB
Tags Source
SHA-256 checksum
How to use checksums
aa008f037baedc3ae3f055c655ddf4184f9c871417c0aff728febbc1ed67b0a6
BLAKE2b-256 checksum
How to use checksums
eb583e13d8ca7d37258b3225c68e983cb4a9346a40f5f418fcf642ff133b8189
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-4.3.0-py3-none-any.whl

Download URL inkletter-4.3.0-py3-none-any.whl
Size 78.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
592a7eca9d5aab89a9d574ee1460d5bb6e3129d4f23adcdfd4b1763122810a55
BLAKE2b-256 checksum
How to use checksums
8800f81b61cfc7dac7f20dafb63b8786cd847c70b733052d3cbfa9f7eb660253
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}

Security for inkletter 4.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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