Skip to main content

mailvault -- Back up and archive email from IMAP and Microsoft 365 mailboxes

mailvault takes mail out of a mailbox and puts it somewhere it will still be readable in ten years: one RFC 822 .eml file per message, in a directory you can walk with ls, each named after the hash of its own content. There is no container format to unpack, no database to keep alive, and nothing that has to be installed before a message can be read again.

It talks to plain IMAP, Gmail, Microsoft 365 (over MS Graph, not merely its IMAP) and Proton Mail through the local Bridge. What people use it for:

  • A backup of a hosted mailbox. Run it nightly; every run after the first costs only the mail that has arrived since.
  • Getting mail out of a mailbox that is filling up. With delete_after_export a message is removed from the server once it is archived — and only once its place has been written down durably.
  • Archiving an Exchange journal mailbox. The journal envelopes are unwrapped, so what lands in the archive is the original mail.
  • Consolidating what has piled up elsewhere. .eml files from other tools are imported into the same archive, where duplicates recognise themselves.

The archive holds no database. Everything a backup writes is written once or replaced atomically, never rewritten in place — which is exactly what goes wrong over SMB or NFS, and the archive is often the only copy of your mail. To query it, a database is built on demand and can be thrown away again: the optional query database. The reasoning is in the deep dive.

Installation

From PyPI, with uv:

$ uv tool install mailvault

That puts the mailvault command on your PATH in an environment of its own. Microsoft 365 over MS Graph is built in, no extra needed. pipx install mailvault and pip install mailvault work just as well, and so does naming a particular release or the development state:

$ uv tool install mailvault==0.12.0
$ uv tool install git+https://github.com/sniner/mailvault

Wheels and a pre-compiled mailvault.exe for Windows -- no Python, no dependencies -- are on the GitHub Releases page.

A new archive

An archive is a directory with a FORMAT file in it, and archive init is what puts one there -- what git init is, down to making the directory if it is not already there and using the one you are standing in when you name none:

$ mailvault archive init /srv/archive/private
/srv/archive/private: archive created
mailvault.toml written -- fill in your mailboxes, then back up

Every other command asks first whether it is looking at an archive, and stops if it is not. An archive written before 0.10 needs mailvault archive migrate once -- the commands say so when it is due, and nothing is deleted.

What goes into mailvault.toml

The configuration lives in the archive, as mailvault.toml. That is where every command looks, so the two cannot drift apart and a copy of the archive carries the recipe along with the mail. It is the one file in an archive that is edited by hand; init leaves a commented one behind to fill in.

One [[job]] per mailbox:

[[job]]
name = "example.org"
server = "imap.example.org"
username = "john.doe@example.org"
password_cmd = "pass show email/example.org"
folders = ["INBOX", "Archive"]

name is what this mailbox is called in every report and in the archive's own records -- pick one and keep it. The rest is the login. Leave folders out and everything the server offers is backed up; port defaults to 993 and tls to true.

A plain password = "..." works, and so does password_cmd, which runs a command and takes its output. Any string field has such a _cmd variant, and they are only evaluated when the command that reads the configuration is given --allow-exec.

Microsoft 365 over MS Graph is a different set of keys in the same shape:

[[job]]
name = "m365"
backend = "msgraph"
tenant_id = "your-azure-tenant-id"
client_id = "your-app-client-id"
client_secret_cmd = "pass show m365/client-secret"
username = "john.doe@example.com"

What a particular provider wants -- Gmail's labels, the app registration behind Microsoft 365, the Proton Bridge, iCloud's unguessable hostname -- is one page of its own: Providers, with a [[job]] to copy for each.

A few settings apply to the whole run rather than to one mailbox and go into a [global] section:

[global]
compress = true      # store the messages compressed with zstd
index_db = true      # keep the query database in step with every backup

Every option there is, and what each one does, is in the configuration reference.

Backing up

From inside the archive, nothing else needs to be said. It is usually worth looking at what the mailboxes offer first:

$ cd /srv/archive/private
$ mailvault folders
example.org::INBOX
example.org::Archive
example.org::Archive/2022
example.org::Trash

Then run the backup:

$ mailvault backup
2024-08-15 10:05:52,275 INFO -- START
2024-08-15 10:05:52,275 INFO -- Archive: /srv/archive/private
2024-08-15 10:05:52,276 INFO -- Job: example.org
2024-08-15 10:05:52,527 INFO -- example.org::INBOX: found 3 messages
2024-08-15 10:05:52,799 INFO -- example.org::INBOX[1]: NEW: id=25652e390168...a234
2024-08-15 10:05:52,799 INFO -- example.org::INBOX[2]: NEW: id=fa1f63a13f91...c9ee
2024-08-15 10:05:52,799 INFO -- example.org::INBOX[3]: NEW: id=800be881dc38...7fa8
example.org: 3 messages seen, 3 stored, 0 already archived

The last line is the answer, and it is the only one on stdout -- the rest is the run talking about itself and goes to stderr, or to --log-file where one is named. mailvault backup > last-night keeps the first and lets you watch the second.

From anywhere else, name the archive -- --archive is what git -C is:

$ mailvault --archive /srv/archive/private backup

Keeping the archive up to date

Run it again. Each folder carries on where the last run left it, so a repeated run costs only the mail that has arrived since, and anything already stored is recognised rather than fetched twice:

$ mailvault backup
2024-08-15 10:09:28,531 INFO -- example.org::INBOX: found 3 messages
2024-08-15 10:09:28,820 INFO -- example.org::INBOX[1]: EXISTS: id=25652e390168...a234
2024-08-15 10:09:28,820 INFO -- example.org::INBOX[2]: EXISTS: id=fa1f63a13f91...c9ee
2024-08-15 10:09:28,820 INFO -- example.org::INBOX[3]: EXISTS: id=800be881dc38...7fa8
example.org: 3 messages seen, 0 stored, 3 already archived

Three numbers rather than one, because 0 stored on its own invites the wrong worry. Mail filed from one folder into another is offered again at its new place, and a run that counted it as stored would report a busy night for mail the archive has held for months.

That is the whole routine -- a cron entry and nothing else. Three things adjust it:

$ mailvault backup --job example.org      # only this job; may be repeated
$ mailvault backup --compress             # store the messages compressed
$ mailvault backup --full                 # re-read every folder, ignoring resume points

A failed download needs no attention: that folder does not advance its resume point, the next ordinary run fetches it again, and nothing is deleted from the server that did not make it into the archive.

What no run can catch up with is a message that arrived and was deleted again between two of them. It was not there at either moment mailvault looked, so no later run can find it: an archive holds what the mailbox held when it was read. That is not a gap to be repaired but the nature of looking in at intervals, and it is the one place where a memory of more mail and a complete archive may honestly disagree. Running more often narrows the window; nothing closes it.

verify is therefore not part of the routine. It answers a different question -- whether the archive still holds what the mailbox holds -- and is worth asking when a message left the archive after it was stored:

$ mailvault verify
example.org::INBOX: 77,592 on server, 43 not archived
example.org: 43 messages missing, run again with --repair

$ mailvault verify --repair
example.org::INBOX: 77,592 on server, 43 not archived, 43 restored
example.org: 43 of 43 messages restored

It compares headers rather than downloading everything, so a large mailbox takes minutes, not hours. See Verify and repair.

The optional query database

The archive holds mail, not answers. To ask it questions, build its query database once:

$ mailvault db create
130,997 messages named by 60 log files, 219,690 of 219,690 locations applied
index.db: written

It lives in the archive as index.db, and it is a copy: everything in it comes from the messages themselves and from the archive's record of where each was seen. Nothing else in mailvault depends on it, and it can be thrown away and built again at any time.

Asking it questions

Every filter given has to match; text matches anywhere in the value and ignores case:

$ mailvault db search --from example.com --since 2024-01-01
2024-03-11  a3f1c8e04b71  info@example.com                Invoice 4711
2024-05-02  9b0d47f2a180  info@example.com                Delivery note 8842
2 messages

--from, --to, --subject, --mailbox, --folder, --since, --until and --limit. The message id in the table is shortened to be read, and it is enough to work with: get takes the beginning of an id the way git takes a short commit hash, and says so when the one you gave fits more than one message. For anything that goes on to another program there is --ids, which prints them whole and nothing else, so a search and an export make a pipeline:

$ mailvault db search --from example.com --ids \
    | xargs mailvault get --output ./invoices/

--csv and --json print the whole result with the ids in full. It is also an ordinary SQLite file, so anything that speaks SQL can read it -- the views it brings along.

Keeping it up to date

db update takes in what the archive has recorded since -- a few small reads rather than a pass over every message:

$ mailvault db update
index.db: 3 log files taken in, 1,206 locations recorded, 412 messages new

Or have it done for you: index_db = true in the [global] section, or --index-db on a single run, and every backup brings it up to date at the end.

You will not have to guess whether it is current. The database records how far into the archive it has read, and a search says so before it prints anything if the archive has moved on:

index.db: behind the archive in 2 places (example.com::INBOX, example.com::Sent)
          -- mail archived since is not in it, take it in with `mailvault db update`

Building it again

When something about it looks wrong, do not investigate it -- replace it. It holds no fact the archive does not:

$ mailvault db create --force

db drop deletes it without asking and without a --force, for the same reason.

What it holds is the mail the archive's log accounts for. An archive filled by an import made before archive import took a --name has none, and archive adopt is what gives that mail a place.

Getting a message out

mailvault get hands over a single message, exactly as it was stored, by the id the reports print -- whole or just its beginning. Name several and give --output a directory to get one file each:

$ mailvault get a3f1c8e04b71 | head -20
$ mailvault get a3f1c8e04b71 -o message.eml

--path answers with where the message lies instead of handing it over, one path per line, which is the form a script wants:

$ mailvault get --path a3f1c8e04b71
/srv/archive/private/mail/a3/f1/a3f1c8e04b71….eml.zst

That file belongs to the archive: it is write-protected, and compressed where the archive is. So --path says where a message is; handing one over ready to read is what get does without it.

Serving the archive to an AI client

mailvault mcp serves the archive to an AI client over the Model Context Protocol: search the mail, read a message as text, fetch an attachment, list the places to search in. Read-only -- nothing a client can say changes the archive or reaches a mailbox -- and answered from the query database, so the server refuses to start until db create has built one. It sits behind an extra, because it carries a protocol stack most installations never start:

$ uv tool install 'mailvault[mcp]'
$ mailvault --archive /srv/archive/private mcp

That second line is what goes into the AI client's configuration: without options the server speaks over stdin/stdout, which is how a desktop client starts it. What to put into which client, --listen for serving HTTP instead, and what the tools answer is one page of its own: The MCP server.

Working on the archive

mailvault archive is the group of commands that work on the archive itself, without touching a mailbox.

$ mailvault archive stats
1,234 emails, 567.8 MiB total

Import existing .eml files -- from another tool, an old backup, a Docuware export with --docuware. --name is what the archive records them under, and it is the only way to ask afterwards which import a message came from. --move removes the source files, --dry-run only counts:

$ mailvault archive import --name docuware-2019 ./my_mails
20,431 messages read -- 38 imported, 20,393 already in /srv/archive/private
recorded as docuware-2019 -- `mailvault db update` takes it in, then `mailvault db search --folder docuware-2019` finds them

Places lists what the archive has mail from -- every mailbox and folder, and every import. These are the names db search takes:

$ mailvault archive places
mailbox    folder                   messages  last seen
gmail.com  INBOX                      12,043  2026-08-12
gmail.com  [Google Mail]/All Mail      4,001  2026-08-12
           docuware-2019               5,412  2026-08-02
3 places, 17,455 messages
  the column adds up to more: a message can be in several places

An empty mailbox column is an import: there is no mailbox behind it.

Compress or decompress the whole archive after the fact:

$ mailvault archive compress
1,234 files compressed, 0 already compressed

Check that the archive is what it claims to be. Every message is read and held against its checksum, which is the only way to find one whose bytes have changed underneath it -- bit rot, a restore that dropped a file, a copy that ran out of disk:

$ mailvault archive check
130,997 messages stored, 130,887 of them accounted for by 60 log files in 59 places
sound -- every message was read and matches its checksum

It repairs nothing and exits non-zero when something is off. --quarantine sets damaged messages aside so they count as missing again and can be fetched back by verify --repair. Where it reports messages that belong to no place, archive adopt takes them in under a name you give.

Compact the metadata log now and then. Every backup writes a small file per folder, and over months they add up:

$ mailvault archive compact
1,204 log files -> 59 across 59 places
41,388 duplicate observations dropped

Migrate an archive written by an older version, once, after upgrading:

$ mailvault archive migrate

Further reading

Providers is the practical one: what plain IMAP, Gmail, Microsoft 365, Proton Mail and iCloud each want in mailvault.toml, and what each of them does that the others do not.

Use cases is the other practical one: whole recipes for situations that take more than one option -- rolling old mail off a mailbox that is filling up, to begin with.

The MCP server is the page for connecting an AI client: which line goes into which client's configuration, serving HTTP and what --allow-remote is for, and what the four tools answer.

The deep dive has what is deliberately left out above:

License

mailvault is free software under the GNU General Public License v3.0 or later.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mailvault-0.16.0.tar.gz (193.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mailvault-0.16.0-py3-none-any.whl (222.5 kB view details)

Uploaded Python 3

File details

Details for the file mailvault-0.16.0.tar.gz.

File metadata

  • Download URL: mailvault-0.16.0.tar.gz
  • Upload date:
  • Size: 193.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","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}

File hashes

Hashes for mailvault-0.16.0.tar.gz
Algorithm Hash digest
SHA256 0bb919240ccc98ce5fd77a43085e3a06842820fa7d7b5b056aa31e3ce6d8e7b9
MD5 b792e25636402f64468710f0ff75b3c3
BLAKE2b-256 e80d9e3bd74bbd7f9242d735e20566c8263abf5c7b08eb13f3c3e0c13f218630

See more details on using hashes here.

File details

Details for the file mailvault-0.16.0-py3-none-any.whl.

File metadata

  • Download URL: mailvault-0.16.0-py3-none-any.whl
  • Upload date:
  • Size: 222.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","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}

File hashes

Hashes for mailvault-0.16.0-py3-none-any.whl
Algorithm Hash digest
SHA256 55a82bcb35839628cc4d0142a179631f7174668ec878de206bad4149f3f383c2
MD5 730d71aee0889c2aefe66287f7b675f2
BLAKE2b-256 dfd328ffd3f1bcb42502f904aa47ec98e661532dca2fe93b52a133b83019f886

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.16.0 This release

2 files

0.15.4

2 files

0.15.3

2 files

0.15.2

2 files

0.15.1

2 files

0.15.0

2 files

0.14.3

2 files

0.14.2

2 files

0.14.1

2 files

0.14.0

2 files

0.13.1

2 files

0.13.0

2 files

0.12.2

2 files

0.12.1

2 files

0.12.0

2 files

0.11.0

2 files

0.10.0

2 files

0.9.2

2 files

0.9.1

2 files

0.9.0

2 files

0.8.2

2 files

0.8.1

2 files

0.8.0

2 files

0.7.0

2 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