md2plain
Markdown in, plain-text email out.
md2plain turns a Markdown letter into a plain-text email body, then copies
it to the macOS clipboard or writes it to a file or stdout. It preserves
complete link targets, code whitespace, and email signature separators while
wrapping ordinary text to a target of 75 display columns.
Paste the result into your mail client's plain-text compose mode. The tool produces the body text; your mail client controls the final MIME format, encoding, and display.
Demo
Input:
# Quarterly Update
This is a **short** note with a [link](https://example.com).
* one
* two
> quoted
Output:
Quarterly Update
================
This is a short note with a link (https://example.com).
* one
* two
> quoted
Why
- Write letters in Markdown without carrying rich-text styling into email.
- Keep the destination of every link, including its query and fragment.
- Keep greetings, addresses, and signatures on their own lines by default.
- Produce readable text with a consistent wrapping target. Complete URLs, email addresses, inline code, and code blocks may exceed that target to preserve their content.
The 75-column setting measures display width, not the character count or encoded line length of a complete email. It does not guarantee compliance with RFC 5322's line-length rules.
Requirements
- macOS (the clipboard is written through AppKit)
- Python 3.10 or newer
Rendering to stdout or a file with copying disabled also works without the macOS clipboard backend.
Install
With pipx:
pipx install md2plain
Or with pip:
pip install md2plain
For development, install from a checkout:
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
Usage
md2plain note.md
In Gmail, enable Plain text mode in the compose window before pasting. Other clients have their own plain-text compose setting. A plain-text clipboard does not force the client to send a plain-text-only message.
To inspect a message you have sent, view its original source and check its MIME content types. A plain-text body is identified by a header such as:
Content-Type: text/plain; charset="UTF-8"
HTML alternatives, attachments, and other MIME structure depend on the mail client. Display wrapping and quote collapsing also depend on the client.
Other output combinations:
# Print without changing the clipboard.
md2plain note.md --print
# Save and copy to the clipboard.
md2plain note.md -o note.txt
# Save without changing the clipboard.
md2plain note.md -o note.txt --no-copy
# Print and save without changing the clipboard.
md2plain note.md --print -o note.txt
# Read Markdown from stdin; a UTF-8 BOM is accepted here too.
cat note.md | md2plain - --print
# Refresh the selected outputs after stable file changes.
md2plain note.md --watch
See examples/email.md and its rendered plain-text result for a bilingual letter with links, quotes, code, and signatures.
Options
| Option | Description |
|---|---|
--width N |
Target display width (default: 75; minimum: 1) |
--watch |
Refresh selected outputs after a file change stays stable for 0.5 seconds; requires a file |
--print |
Write to stdout and disable clipboard copying |
-o, --output FILE |
Also save to a UTF-8 text file; copying remains enabled unless disabled |
--no-copy |
Disable clipboard copying; use with --print or -o |
--soft-breaks |
Join ordinary paragraph soft newlines with spaces; code and signature separators stay intact |
--version |
Print the version and exit |
Input must be valid UTF-8, with or without a BOM. Invalid encoding is an error; it is never silently replaced with different characters. Failed input does not change the output file or clipboard. Output cannot refer to the input file, including through a symbolic or hard link. Files are saved using an atomic replacement to avoid leaving a partially written result.
Watch mode waits for a change to remain stable for 0.5 seconds before converting it. Temporary empty files, unreadable files, and decoding errors keep the last successful result; watching continues when the file is saved again or recreated. Clipboard failures are reported as errors.
Output format
| Markdown | Output |
|---|---|
# Heading |
heading + ==== underline |
## Heading |
heading + ---- underline |
### Heading |
heading + ~~~~ underline |
| Paragraph | wrapped to a target of 75 display columns |
- item |
* item, with hanging indent |
1. item |
1. item |
> quote |
> quote |
| Code block | indented by four spaces; parser-provided whitespace and line breaks kept; no wrapping |
| Inline code | parser-provided spaces kept; not split across lines |
[text](url) |
text (url); full target retained in every context |
~~deleted~~ |
~~deleted~~ |
 |
[图片:alt] (src); empty alt uses [图片] (src) |
| Table | naturally aligned columns when they fit; otherwise one header: value line per column, with a blank line between records |
| Single newline | kept (signatures and dates stay on their own lines) |
Exact -- signature separator |
kept, including the trailing space and inside blockquotes |
Links keep their full query strings, fragments, and relative paths. The repeated target is omitted only when identical to the label. URLs and email addresses move to the next line as a whole when needed; they may exceed the width. Image references retain their source but do not attach or embed the image. Tables containing tabs also use field records, preserving the tab characters without depending on their position in a column.
Lists and blockquotes reserve room for their prefixes before wrapping.
Unicode text wraps at complete grapheme clusters, so a combining accent or
joined emoji is not split. Display width is estimated with wcwidth for
monospaced text; actual rendering varies with fonts and mail clients.
How it works
markdown-it-pyparses CommonMark plus tables, strikethrough, and autolinks.- The renderer retains protected content through wrapping, handles email signature separators before Markdown heading rules, and renders headings, lists, quotes, and tables.
- The CLI strictly decodes UTF-8 (BOM tolerant), saves files atomically, and writes plain text to the selected outputs.
Limitations
- Clipboard copying requires macOS and AppKit.
- Bold and italic styling is removed. Link destinations remain text; the mail client decides whether to make them clickable.
- Images are references, not attachments. YAML front matter, footnotes, math, and Mermaid diagrams do not have dedicated conversion support.
- Tables use spaces, so proportional fonts may break their alignment.
- The tool does not generate
.eml, set MIME headers, or send mail. Puretext/plaindelivery must be selected and checked in the mail client. - Clients may reflow text or collapse quoted sections in their display.
Development
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m unittest discover -s tests -v
.venv/bin/ruff check src tests
.venv/bin/black --check src tests
The ./md2plain script in the repository root is a development launcher; it
runs the package from .venv. Installed users get the md2plain command from
pyproject.toml.
Releasing
Releases are published by GitHub Actions through PyPI Trusted Publishing, so no API token is stored in the repository.
One-time setup on PyPI: open the md2plain project, go to
Settings → Publishing, and add a trusted publisher with:
| Field | Value |
|---|---|
| Owner | hoshF |
| Repository | md2plain |
| Workflow | release.yml |
| Environment | pypi |
To cut a release:
- Bump
__version__insrc/md2plain/__init__.py. - Add an entry to CHANGELOG.md.
- Commit, tag, and push:
git tag v0.1.2
git push origin v0.1.2
The Release workflow then checks that the tag matches the package version,
runs the tests, builds the sdist and wheel, and publishes them to PyPI.
License
MIT. See CHANGELOG.md for release history.
Metadata
Release files for md2plain 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| md2plain-0.1.1.tar.gz | 25.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| md2plain-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 39.8 kB
Release files / md2plain-0.1.1.tar.gz
| Download URL | md2plain-0.1.1.tar.gz |
|---|---|
| Size | 25.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
df86090f31111a57a8f77db64fa22bb20e0cec73b7d54a85bb7d18a4cc950554
|
|
BLAKE2b-256 checksum How to use checksums |
162fdf9b36fae78b42495ffbd80a82f75c26c0f6f30cf9ed10a8a606ce404e9f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logRelease files / md2plain-0.1.1-py3-none-any.whl
| Download URL | md2plain-0.1.1-py3-none-any.whl |
|---|---|
| Size | 14.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4064b796592b2f98049146019d13684791b5f508ba360134900be3f01b6a2c93
|
|
BLAKE2b-256 checksum How to use checksums |
35cbcb498722a03f03688d6a7af19e864abc628887158464fcf61b0cf7b09a77
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logSecurity for md2plain 0.1.1
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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for md2plain-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logA 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.