Skip to the documentation
CLI

Files, imports and settings

Every command for the files the mailbox holds, importing an old mailbox or a Resend account, the calendar found in mail, and the settings of the workspace and of each address.

Overview

Five namespaces cover what sits around the mail itself. openemail files is the Files page: every attachment sent and received and every file uploaded, with its bytes and the download links it went out as. openemail imports brings an old mailbox into one address, and openemail provider-imports brings a Resend account across. openemail calendar reads the events found in mail, and openemail settings reads and changes the mailbox preferences and the signature and tracking of each address.

  • An id says what it names: file_ for a file, imp_ for a mailbox import, pimp_ for a Resend import and cal_ for a calendar event, each followed by 24 hex characters. A download link has an id of its own, from files list-links or files create-link.
  • --address-id on an import is the id of an address, from openemail domains list-addresses <id>, not the address itself.
  • The namespaces also answer to file, import, provider-import or providerImports, and setting. Verbs take the usual aliases where they have them: ls for list, show and view for get, new and add for create, edit for settings update, and rm, del and remove for files delete.
  • None of these commands asks a browser sign-in for a verification code. The ones that change something for good ask you to confirm, and --yes answers for a script.
  • openemail <command> --help lists every argument and flag with its type, the scope the call needs, its method and path, and what comes back. Add --json for the same page as data.

Files

openemail files reads and manages every file the mailbox holds: attachments that came in, attachments that went out, and files uploaded to the Files page. openemail open files opens the same page in the web app.

CommandWhat it does
openemail files listList one page of files with name, type, size, direction, the address each came to and whether it can be deleted
openemail files get <id>Read one file, with the thread and message it belongs to, its usage and deletable
openemail files statsCount the files and the bytes they take, split into received, sent and uploaded, with the top types and addresses, the last 30 days and the links still working
openemail files download <id>Fetch the bytes of a file exactly as stored, into --out <file> or to stdout when piped
openemail files list-links <id>List the download links a file went out as, newest first, with how often each was fetched and whether it was revoked
openemail files create-link <id>Publish the file at a new public download link that opens without signing in
openemail files revoke-link <id> <link-id>Stop a public link for good, in mail already sent too. Asks you to confirm
openemail files upload <data>Upload a file from a path, or from stdin with -, up to 100 MB
openemail files delete <id>Delete an upload that nothing depends on, for good. Asks you to confirm
openemail files delete-many <ids...>Delete up to 100 files in one call and report the ones kept and the ones not found. Asks you to confirm
  • files list narrows with --q (the name and the type), --kind (image, pdf, audio, video or text), --direction (inbound, outbound or uploaded), --address, --since and --until, and sorts with --sort (newest, oldest, largest or name). --since and --until take an ISO 8601 date or date-time: --since includes its moment and --until stops before it.
  • Only an upload that nothing depends on can be deleted, and deletable says so ahead of time. usage says what keeps any other file: received, sent, linked for an upload whose download link still works, or scheduled for one attached to a message that has not gone out. delete refuses those with 409 file_in_use. delete-many deletes what it can, lists the rest in kept with a reason each, and lists unknown ids in missing.
  • upload reads the file at the path you give and stores it under that file’s name, with the type its extension implies. --filename and --content-type override them. From stdin, - needs --filename, and without --content-type the type is application/octet-stream. The upload waits up to 10 minutes, which --timeout-ms changes.
  • The id upload returns attaches the file to a send as { "fileId": "…" } in openemail emails send --attachments, which is how a file over the 5 MB inline limit goes out.
  • An empty file is 400 upload_empty, a program or script (judged by its name) is 400 upload_dangerous, and a file over 100 MB is 413 upload_too_large. A workspace keeps up to 10 GB of uploads and takes 500 uploads an hour.
  • download needs --out <file> on a terminal and stops with exit code 2 without it. Piped, the bytes go to stdout, and --out - asks for stdout too. The whole file is held in memory.
  • create-link makes a new link on every call, with its own download count, and the link works until it is revoked or the file is deleted. It lives on the files domain of --domain when that domain has one, such as files.acme.com, else on the files domain of the file’s address, else on the API address. A program or script is refused with 422 file_unshareable.
  • revoke-link is safe to repeat: a link already revoked comes back unchanged.
  • A key or sign-in limited to particular addresses or domains sees only the files that arrived at them, never the files uploaded for the whole workspace, and its own uploads belong to the first address it holds.
  • The files index starts on the day it shipped. Older attachments are still on their messages, where openemail threads list-attachments <id> reads them.

Mailbox imports

openemail imports brings an old mailbox into one address: Google Takeout archives, .mbox files from Apple Mail, Thunderbird and most desktop apps, .eml files, and .zip or .tgz archives holding any of those, up to 100 GB a file and 50 files an import. Threads, dates and labels come across. Imported mail is quiet: it runs no rules, forwards, notifications, summaries or webhooks.

import-files is the one most people need: it creates the import, uploads every file in parts with a progress line, and starts it. The other verbs are those steps one at a time, for finishing an upload that stopped or for driving it yourself. create returns the plan, chunkBytes and each file’s chunks, upload-chunk sends one part, upload-state says which parts arrived, and start queues the import. openemail open imports shows the same imports in the web app.

CommandWhat it does
openemail imports listList one page of the imports in the workspace, newest first, with status, bytes read and counts. --address-id keeps one address
openemail imports get <id>Read one import with its status, progress and counts. Poll it until it reads completed, failed or cancelled
openemail imports create --address-id <value> --files <json|@file|->Create an import from a list of { name, bytes } and get its upload plan, with status uploading
openemail imports upload-state <id>For each file, the indexes of the parts already stored, so a stopped upload sends only what is missing
openemail imports upload-chunk <id> <file> <chunk> <data>Upload one part of one file, from a path or from stdin with -. Sending a part again replaces it
openemail imports start <id>Check that every part arrived, recognise each file’s format and queue the import
openemail imports cancel <id>Stop the import at its next checkpoint. Mail already imported stays. Asks you to confirm
openemail imports list-failures <id>List each message or archive entry that did not come across, with the reason
openemail imports delete-upload <id>Delete the uploaded files. Mail already imported stays. Asks you to confirm
openemail imports import-files --address-id <value> --files <path>Create, upload and start an import in one command, and return once it is queued
  • The address has to be one on the workspace’s own domains that the key or sign-in may act for. Only one import runs per address: another while one is queued or running is 409 already_running.
  • import-files takes --files once per file, or several paths separated by commas, and sends each under its own file name. --options takes JSON such as {"keepInbox":false,"includeSpam":true}. On create the same three are switches: --options-keep-inbox, --options-include-spam and --options-include-trash, each turned off with --no- in front, as in --no-options-keep-inbox.
  • keepInbox is on by default, so mail from the old inbox lands in Inbox with its unread state. Off files everything under Archive. Spam and trash stay behind unless you include them.
  • create takes --files as JSON, inline or from a file with @path, and each bytes must be the exact size of the file you then upload.
  • Each part is exactly chunkBytes long, except the last part of a file, and a part of the wrong length is 400 bad_chunk. start refuses a file with parts missing with 412 missing_chunks, and one that is not an archive or a mailbox with 400 unsupported_file. On an import that already left uploading, it returns the import unchanged.
  • If a part still fails after its retries, import-files stops with the import left in uploading. The block below sends the missing parts of the first file and starts it.
  • An import reports processedBytes out of totalBytes, and counts with seen, imported, duplicate, filtered, failed and threads. lastError is set only when status is failed.
  • list-failures gives each failure a reason: too-large (over 50 MB), unparseable, no-date, storage-error, unreadable-entry, encrypted-entry or archive-limit, with the subject and sender where they could be read.
  • cancel on a finished import is 409 not_cancellable. delete-upload cancels an import that is still uploading, and refuses one that is queued or running with 409 still_running, so cancel that first.
Finish an upload that stopped
openemail imports get "$import_id" --json > import.jsonopenemail imports upload-state "$import_id" --json > state.jsonchunk=$(jq .chunkBytes import.json)chunks=$(jq '.files[0].chunks' import.json)for i in $(seq 0 $((chunks - 1))); do  jq -e --argjson i "$i" '.received[0] | any(. == $i)' state.json > /dev/null && continue  dd if=takeout-001.zip bs="$chunk" skip="$i" count=1 2> /dev/null |    openemail imports upload-chunk "$import_id" 0 "$i" -doneopenemail imports start "$import_id"

Imports from Resend

openemail provider-imports moves a workspace across from Resend with one full-access Resend API key. Suppressions keep their reason, segments become audiences, subscribed contacts arrive with their names, and templates arrive as HTML templates with their variables declared. Webhooks are registered switched off, because deliveries here are signed differently. A signing key or a token cannot leave Resend, so domains and API keys come back as a checklist in the report instead.

CommandWhat it does
openemail provider-imports inspect <api-key>Count what the Resend account holds, per resource, before importing. Nothing is stored and the key is not kept
openemail provider-imports create --resend-key <value> --resources <a,b>Queue an import of the resources you name
openemail provider-imports listList one page of the imports from sending providers, newest first, each with its report
openemail provider-imports get <id>Read one import: its status, the step it is on and the report so far
openemail provider-imports cancel <id>Stop the import and erase the Resend key. What already came across stays. Asks you to confirm
  • Pass the Resend key as @path to read it from a file, or as - to read it from stdin, so it stays out of your shell history and the process list. A key typed on the command line still works, with a warning. --dry-run prints the body with the key redacted.
  • --resources takes any of suppressions, audiences, contacts, templates, webhooks, domains and api-keys, comma separated or repeated.
  • A sending-only key is 400 restricted_key. Create a full-access key at Resend for the import, and revoke it afterwards.
  • inspect reads the first page of each resource and returns counts, one { count, more } per resource, with more set where a resource runs past its first page.
  • One import runs per workspace at a time, and a second is 409 already_running. Running the same import twice duplicates nothing. The key is held encrypted only while the import runs, and is erased when it ends, fails or is cancelled.
  • Contacts who unsubscribed at Resend are left out and counted in report.contacts.unsubscribed.
  • report.domains lists each Resend domain with a state: absent when it is not in this workspace, added when it is but is not verified, and verified, with the records it had at Resend. Add a missing one with openemail domains create. report.apiKeys names the keys to create again with openemail keys create.
  • With a browser sign-in only the workspace owner may use these commands, and a member gets 403 owner_only. An API key is not held to that. A key or sign-in limited to particular addresses or domains cannot inspect or create an import, since audiences, suppressions and templates belong to the whole workspace.
  • create also needs the scope of each resource it brings in: audiences:write for audiences, contacts:write and audiences:write for contacts, templates:write for templates, webhooks:write for webhooks and domains:read for domains.

Calendar

openemail calendar reads the calendar events found in mail. All three commands only read. list-events expands every event into its occurrences inside a window you give, so a weekly meeting is one row per week, and get-event and get-event-ics read one event whole. openemail open calendar opens the calendar in the web app.

CommandWhat it does
openemail calendar list-events --from <when> --to <when>List one page of occurrences between two moments, sorted by start time, each pointing back to its event through eventId
openemail calendar get-event <id>Read an event with its attendees and their answers, its recurrence rule, excluded dates, organiser and reminders
openemail calendar get-event-ics <id>Download the event as an iCalendar document, printed or written to --out <file>
  • --from and --to are ISO 8601 date-times such as 2026-10-01T00:00:00Z. --to must be after --from and at most 366 days later, since a repeating series has no end to list up to. A missing bound is 422 invalid_parameter. A bound that does not parse, a reversed or overlong window, or an unknown zone is 400 invalid_parameter.
  • --timezone is an IANA zone such as Europe/London, UTC when left out. It expands the events stored without a zone of their own, which decides the day an all-day event lands on.
  • get-event and get-event-ics take the eventId of an occurrence, not the iCalendar uid. A recurring series is one event, so its start and end describe the first instance.
  • Cancelled events are listed too, with status CANCELLED. myPartstat is the answer recorded for any address the workspace holds on its own domains, and null when none of them is an attendee.
  • Paging carries on with --cursor and the same window. A key or sign-in limited to particular addresses sees only the events one of them organises or attends, and any other event is a 404.
  • get-event-ics declares METHOD:PUBLISH, so importing it shows the event as it stands rather than offering to accept or decline. Timed events are written as UTC instants and all-day events as dates. DTSTAMP is the time of the download, so two downloads of an unchanged event differ on that line.

Settings

openemail settings reads and changes the mailbox preferences, such as timezone, language, time format and the blocklists. Four settings belong to an address rather than the workspace: signature, openEmailSignature (the OpenEmail footer), trackOpens and trackClicks. Name the address with --address, or *@acme.com for the catch-all of a domain.

CommandWhat it does
openemail settings getRead every setting with defaults filled in. With --address, the four per-address settings read the way a send from that address resolves them
openemail settings updateChange the settings you pass and keep the rest. The answer is every setting, read back after the write
  • Without --address, get reads the four per-address settings as the built-in defaults: no signature, the OpenEmail footer on, and open and link tracking on. With it they come from the address’s own values, then from its domain’s catch-all when the catch-all caught that address, then from the defaults. A plus address with no settings of its own reads its base address’s.
  • Without --address, update refuses --signature, --open-email-signature, --track-opens and --track-clicks with 422 address_required and writes nothing. With it, update takes those four only, and anything else is 422 not_per_address. The address has to be in this workspace, or *@domain for a verified domain here with its catch-all on.
  • The switches turn off with --no- in front: --no-open-email-signature, --no-track-opens and --no-track-clicks.
  • --signature is HTML of at most 150,000 characters, sanitised on write, so what comes back is what will be sent. An empty string removes it. Whether a send adds it is up to the send: see --signature in openemail emails send --help.
  • The flags cover the common fields. --data takes any stored setting as JSON, inline, from a file with @path or from stdin with -, such as timeFormat, weekStart, undoSendEnabled, blockedSenders or blockedDomains. Keys the settings do not know are dropped without an error, and a known key with the wrong type is 422 invalid_parameter.
  • A list such as blockedDomains is replaced whole, so read it first and send it back with your change, as the example below does. A blocklist entry too short to be a rule is 422 blocklist_entry_too_broad.
  • --timezone and --language are saved as given, without checking, so check a zone or a language code before you send it.
  • The privacy fields, externalImages, trustedSenders, blockedSenders, blockedDomains, blockedWords and useDefaultBlockedWords, belong to the workspace. A key or sign-in limited to particular addresses or domains cannot change them, and may name in --address only an address it holds, or a catch-all on a domain it holds whole. Either refusal is 422 capability_unsupported.
  • An update with nothing in it changes nothing and returns the current settings. defaultEmailAlias only preselects the From address in the web app composer, and the API never uses it to choose a sender.

Examples

Upload a file and share it by link
file_id=$(openemail files upload ./price-list.pdf --json | jq -r .id)openemail files create-link "$file_id" --domain acme.com --json | jq -r .urlopenemail files list-links "$file_id"openemail files download "$file_id" --out copy.pdf

Each create-link makes a new link, so run it once and read the link back with list-links. revoke-link stops it later, in mail that already went out as well.

Clear out every upload that nothing uses
openemail files list --direction uploaded --all | jq -r 'select(.deletable) | .id' > deletable.txtwc -l < deletable.txtxargs -r -n 100 openemail files delete-many --yes --json < deletable.txt | jq -c '.kept[]'

With --all in a pipe, files list prints one file per line. xargs -n 100 hands delete-many at most 100 ids a call, and any file that became used in between comes back in kept with its reason rather than failing the call.

Import an old mailbox and follow it
domain_id=$(openemail domains list --all | jq -r 'select(.domain == "acme.com") | .id')address_id=$(openemail domains list-addresses "$domain_id" --all | jq -r 'select(.address == "[email protected]") | .id')openemail imports import-files --address-id "$address_id" --files ~/Downloads/takeout-001.zip --files ~/Downloads/takeout-002.zip --json > import.jsonimport_id=$(jq -r .id import.json)until openemail imports get "$import_id" --json | jq -e '.status != "queued" and .status != "running"' > /dev/null; do  sleep 60doneopenemail imports get "$import_id" --json | jq '{status, counts, lastError}'openemail imports list-failures "$import_id" --json | jq -r '.data[] | [.reason, .subject] | @tsv'

--dry-run on import-files prints the POST /imports it would send, with each file’s name and size, and uploads nothing. list-failures reads 100 at a time: pass the nextCursor it printed as --after for the next page.

Move from Resend
openemail provider-imports inspect @resend-key.txtopenemail provider-imports create --resend-key @resend-key.txt --resources suppressions,audiences,contacts,templates,webhooks,domains,api-keys --json > resend.jsonpimp_id=$(jq -r .id resend.json)until openemail provider-imports get "$pimp_id" --json | jq -e '.status != "queued" and .status != "running"' > /dev/null; do  sleep 10doneopenemail provider-imports get "$pimp_id" --json | jq '.report | {contacts, domains: [.domains[] | {name, state}], apiKeys: [.apiKeys[].name]}'
Read a month of meetings
openemail calendar list-events --from 2026-10-01T00:00:00Z --to 2026-11-01T00:00:00Z --timezone Europe/London --all |  jq -r 'select(.status != "CANCELLED") | [.start, .summary, .myPartstat] | @tsv'openemail calendar get-event cal_8d3f0a2b9c4e41f7a6b5c2d1 --json | jq '.attendees[] | {email, partstat}'openemail calendar get-event-ics cal_8d3f0a2b9c4e41f7a6b5c2d1 --out standup.ics
Signature, tracking and the blocklist
openemail settings get --address [email protected] --json | jq '{signature, openEmailSignature, trackOpens, trackClicks}'openemail settings update --address [email protected] --signature "$(cat signature.html)" --no-track-clicks --dry-runopenemail settings update --address [email protected] --signature "$(cat signature.html)" --no-track-clicksopenemail settings update --address '*@acme.com' --no-track-opensopenemail settings update --timezone Europe/London --language enopenemail settings get --json | jq -c '{blockedDomains: ((.blockedDomains + ["spam.example"]) | unique)}' |  openemail settings update --data -

The dry run prints the PATCH /settings?address=sales%40acme.com and its body without sending it. The catch-all line sets tracking for every address the catch-all of acme.com picks up, and the last line adds one domain to the blocklist without dropping the others.

Scopes, confirmations and errors

ScopeCommands
files:readfiles list, get, stats, download, list-links
files:writefiles create-link, revoke-link, upload, delete, delete-many
threads:readimports list, get, list-failures
threads:writeimports create, upload-state, upload-chunk, start, cancel, delete-upload, import-files
settings:readprovider-imports list, get, and settings get
settings:writeprovider-imports inspect, create, cancel, and settings update
calendar:readcalendar list-events, get-event, get-event-ics
  • A sign-in or key without the scope stops with exit code 4, names the missing scope and says how to get it.
  • files revoke-link, files delete, files delete-many, imports cancel, imports delete-upload and provider-imports cancel ask you to confirm. Answering no exits with code 10 and changes nothing. Unattended and without --yes, they stop with exit code 2 before anything is sent.
  • No command here asks for a verification code, with a browser sign-in or an API key, so --yes is all a script needs.
  • --dry-run prints the request a change would send, with its body, and exits with code 0 without sending it or asking you to confirm. A file body is shown as its size and type.
  • files list, files list-links, imports list, provider-imports list and calendar list-events read one page: --limit takes 1 to 100 and the server sends 25 when it is left out, and --cursor takes the nextCursor of the page before. --all reads every page, --max <n> stops after that many items, and --ndjson, or --all in a pipe, prints one JSON object per line. With --json they print one { items, hasMore, nextCursor } document.
  • imports list-failures pages its own way: --after takes the nextCursor of the page before, --limit is at most 100, the default, and there is no --all. With --json it prints { object, data, nextCursor }.
  • Reads and the changes that land the same way twice are retried after a network failure: files revoke-link, imports upload-chunk, imports start and settings update. Every other change is sent once. After a lost answer, look before trying again: a second upload stores a second copy and a second create-link makes a second link, a 404 on your second files delete means the first one worked, and a second delete-many lists what the first deleted in missing.
  • A refusal exits with the code of its status: 4 for a 403, 5 for a 404, 6 for a 409, 7 for a 400 or a 422, and 8 for a 429.

Where to go next

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.