mail2md: turn .eml files into notes with YAML frontmatter (macOS CLI)

Disclaimer

Is this project open source? Yes (MIT)
Is this project completely free? Yes
Does this project use AI beyond the author’s ability to comprehend how it works? No. It contains no model and makes no network calls.


I keep a fair amount of correspondence in my vault: job applications, client threads, anything with a decision in it. Getting a mail in there used to be copy, paste, and retype the metadata by hand.

So I wrote a small command line tool for it. One .eml in, one .md out:

mail2md Mail.eml
---
created: 2026-06-15T09:41
from: "Jane Doe <[email protected]>"
to: "Anton Fillmann <[email protected]>"
via:
subject: "Project inquiry"
attachments:
---

Hallo Anton,

...

The point of the frontmatter is that the note becomes queryable. You can sort by created, filter by from, or list every mail that carried an attachment. Most converters I found write that same information into the prose instead, as a heading plus a bullet list, which reads fine and answers no questions.

A few details that took the longest to get right:

  • created is rendered in your local time, not the sender’s timezone, so it sorts correctly next to the rest of your notes.
  • Quoted reply chains stay in one file. Earlier versions split a thread into one note per message, and I removed that again: interleaved replies have no clean boundary, quote separators are client- and language-specific, and no RFC defines them. A converter that guesses there produces confident garbage. Splitting is a judgment call, so it belongs to whoever knows the context.
  • Nothing is overwritten silently. An existing target with identical content is skipped, one that differs aborts unless you pass --force.
  • Attachments are listed in the frontmatter and can be extracted with --extract-attachments. Inline images are deliberately not treated as attachments, since a named inline image is a signature logo or a tracking pixel far more often than a file someone meant to send.

How it compares. Postbox ( GitHub - istefox/Postbox: Obsidian plugin to import and manage .eml email files in your vault · GitHub ) is the closest thing I know of, and parts of it are better than mine: it renders inline cid: images, keeps HTML tables, reads .msg, and de-duplicates by Message-ID. It is a plugin, so it is desktop-only and Obsidian has to be running. mail2md is the better fit when the conversion should happen without Obsidian in the loop, in a shell pipeline, a cron job, or a Makefile. Several EML viewer plugins exist too, but they render the mail rather than convert it, and the file stays an .eml.

Install (macOS 13+):

brew install antonfill/tap/mail2md

The formula builds from source against Swift 6.0, so Xcode 16 or newer has to be installed. There is no prebuilt binary yet, and that is the honest catch: if there is no Xcode on your Mac at all, this is a large download for a small tool.

MIT licensed, source at GitHub - AntonFill/mail2md: Convert .eml files to Markdown with YAML frontmatter. A local-first CLI for macOS. · GitHub . JSON output and stdin/stdout piping are on the roadmap.

There is a companion tool in the same family, realdate ( GitHub - AntonFill/RealDate: A CLI tool that extracts dates from filenames, sets macOS file timestamps, and cleans up filenames for better file system sorting. · GitHub ), which takes the date out of a filename like 2026.06.07 Invoice.pdf and writes it into the file’s timestamps. Both are local-first: no account, no cloud, no network access.

I would like to know how other people handle this. What do you do today with mail you want to keep in your vault, and which part of it annoys you enough that you have thought about it more than once?

1 Like

Small update, since this thread is the release channel for the tool: v1.1.0 is out, and it is the release that matters most if you keep mail in a vault.

Until now, a mail that consisted of its attachments turned into a note that looked empty. Fourteen screenshots and a signature line came out as the signature line. The frontmatter named the files, the body said nothing about them, and it looked like the tool had given up halfway.

Since 1.1.0 the attachments close the body: images embedded two per row, documents linked, no invented heading and no rule above them. And --attachment-name takes a naming pattern from outside, so extracted files land under your own convention instead of their MIME names:

mail2md Mail.eml \
  --attachments-dir ./attachments \
  --attachment-name "{date:yyyy.MM.dd} {time:HH.mm} ENCL {name}"
---
created: 2026-08-18T07:52
from: "Jane Doe <[email protected]>"
subject: "Kitchen photos and the quote"
attachments:
  - "[[2026.08.18 07.52 ENCL wall-left.png]]"
  - "[[2026.08.18 07.52 ENCL wall-right.png]]"
  - "[[2026.08.18 07.52 ENCL Quote 2026-08.pdf]]"
---

Hi Anton,

photos of the two walls and the quote as PDF.

|  |  |
|---|---|
| ![[2026.08.18 07.52 ENCL wall-left.png]] | ![[2026.08.18 07.52 ENCL wall-right.png]] |

[[2026.08.18 07.52 ENCL Quote 2026-08.pdf|Quote 2026-08.pdf]]

The placeholders are {name}, {ext}, {date} and {time}. The last two render the mail’s own timestamp, which is what makes an attachment sort directly behind the mail it came from, and they take a format string after a colon.

Two decisions in there could have gone the other way, so they are worth stating:

  • The attachment block only appears when the files are actually written. An embed pointing at a file nobody extracted is a dead link, and a default output full of dead links would have been worse than the previous silence. Without the extraction flags, 1.1.0 produces byte-identical output to 1.0.2.
  • The tool does not learn a vault schema, it accepts one. Whether a screenshot should be called 01 Kitchen wall or IMG_0001 is a judgment about content, and a converter has no business guessing it.

Upgrading is brew upgrade mail2md. One caveat worth knowing: if you re-run the tool with extraction over a note that 1.0.x wrote, you get a conflict and exit 1 rather than a silent overwrite. That is the safety check working as intended, and --force is the answer.

Still on the roadmap: JSON output and stdin/stdout piping.

One question, since naming is the part I had to decide alone: what does an attachment have to be called for you to find it again a year later, without opening the note it came from?

An update on realdate, the companion tool from the first post, since this thread is where both tools get their release notes. realdate reads the date from a filename like 2026.06.07 Invoice.pdf and writes it into the file’s creation and modification date, so Finder and Obsidian sort by the real date instead of the day you saved the file. Three releases since then, 1.3.0 to 1.4.1.

A name with only a day can get a time of day. Until now 2026.06.07 Invoice.pdf landed at midnight. --default-time sets the clock time instead:

realdate --default-time 09:00 --rename "2026.06.07 Invoice.pdf"

It only fills a gap and never overrides a measurement: a name with its own time keeps it, and a file that was already created or edited earlier on that day keeps that time.

Errors behave like errors. Since 1.3.0, failures go to stderr with a non-zero exit code, 1, or 64 for a wrong command line. Before, they went to stdout with exit 0. If a script of yours runs realdate … && next-step, it now stops where it should, and that change in behavior is why 1.3.0 is a minor and not a patch. Skips stay on stdout with exit 0 (a name without a date, a symlink, a subdirectory without -r), the way cp -v reports what it did.

Two bugs that never said a word:

  • --format "", typically an unset variable in --format "$FORMAT", dated every file to 2000-01-01, including files without a date in their name, and renamed them, with exit 0. An empty format is now rejected before any file is touched.
  • APFS pulls a creation date down to the modification date when the modification date is set to something earlier. realdate raised the modification date only to midnight of the named day, so a creation time it meant to keep could end up at midnight while -v reported it as kept. It now raises the modification date to the creation date the file actually ends up with.

Smaller. realdate no longer depends on Swift Argument Parser. The Homebrew install went from 1.7 MB to under 300 KB and no longer clones a package during the build. 1.4.1 brings back help text that wraps to the width of your terminal.

Upgrading is brew upgrade realdate.

One question, since the defaults are yyyy.MM.dd and yyyy.MM.dd.HH.mm and anything else needs --format: which date format do your file names use, and where does it come from, a scanner, a download, your own habit?

1 Like

And mail2md: four releases since 1.1.0. The short version is that HTML mail now comes out the way your mail app shows it, no picture disappears without a word, and stdin/stdout piping from the roadmap in both earlier posts is in.

HTML mail as you saw it (1.2.0). Up to 1.1.0 the tool read the plain-text part wherever a mail had one, and that part is missing the buttons and their links, the headings, the lists and the tables. Now it takes the part a mail client shows, the last alternative per RFC 2046, and lays it out the way a browser does: a data table becomes a Markdown table, a layout table becomes lines, and hidden preview text stays out. Mail that is not UTF-8 is read in its declared charset, and cc: is in the frontmatter. 1.3.1 stops bold and italic from breaking into pieces when a mail splits them across tags.

Inline pictures (1.3.0). A picture the mail shows from its own parts (cid:) used to vanish from the note, including the screenshot the mail was about. I tried to tell a content picture from a logo and could not: no property separates them, and the best rule I found let two ad banners through. So the tool does not guess. Every cid: picture the note leaves out is listed on stderr with its display size, and --inline-images writes all of them as files and embeds each one where it stood.

stdin and stdout (1.4.0). - reads the mail from standard input, -o - writes the note to standard output:

mail2md - < Mail.eml > note.md
mail2md Mail.eml -o - | grep '^from:'

The note is byte for byte what the file would have been. Extracting attachments in that mode needs --attachments-dir, since there is no “next to the output”. Swift Argument Parser is gone here as well, which makes the install smaller.

Upgrading is brew upgrade mail2md. A note that 1.1.0 or earlier wrote will differ now, the new cc: line alone sees to that, so re-running over it stops with a conflict and exit 1, and --force overwrites it. Between 1.3.1 and 1.4.0 nothing changes: over my own archive of 642 mails both write identical notes.

One correction to my first post: it said macOS 13+. The formula builds from source with Xcode 16, and Xcode 16 needs macOS 14.5, so that is the real minimum, for realdate too.

Still on the roadmap: JSON output.

One question, since the pictures were the decision I made alone: when a mail shows pictures inline, do you want them in the note by default, or only when you ask for them?

1 Like