Rail is a terminal mail client for Gmail and Microsoft 365, written in Rust. It keeps a local copy of your mail in SQLite, synced by a small background daemon, so every list, search and open is a local query and the interface never waits on the network. It is built for people who live in a terminal and want something in the spirit of mutt with threads, a unified inbox across accounts, readable HTML and a command palette. It also speaks MCP, so an AI coding agent can help you set it up without ever seeing your secrets.
Rail is pre-1.0. Account setup, background sync, search, reading mail, acting on threads (archive, trash, star, read, move) and writing mail (new messages, replies and forwards, with undo send, and drafts saved to the account's Drafts) work today for Gmail and Microsoft 365. Labels are Gmail's; on Microsoft 365
mmoves to a folder instead.
Contents
- Status
- Install
- Quick start
- Reading mail
- Doing things to mail
- Writing mail
- Account setup
- Using Rail from an AI agent (MCP)
- Security and privacy
- Configuration
- Commands
- Troubleshooting
- Contributing
- License
Status
Works today
- Setup wizard (
rail setup) and a flag form for scripts (rail account add), including organisation presets and Microsoft admin consent. - OAuth login for Gmail / Google Workspace and Microsoft 365; tokens kept in the OS keyring, a private file, or 1Password.
- Gmail and Microsoft 365 sync into the local store: labels or folders, message envelopes, and
bodies of recent threads, kept current by a background daemon that can start at login
(
rail daemon install). - Microsoft 365 over Microsoft Graph: every mail folder, threads by conversation, read and flag
(star) state, new mail within one poll interval, server search, marking read on open,
archive, trash, star, read or unread and move to a folder, with undo, and writing: new
messages, replies in the Outlook conversation, forwards, attachments up to 25 MiB and drafts in
Outlook's Drafts.
lanswersOutlook uses folders, not labels: press m to move.See Microsoft 365 mail. rail searchover the local store and, through each account's server (Gmail's search, Microsoft Graph's$search), the mailbox itself.- Reading mail in the terminal: a unified inbox and per-account and per-label lists over the local
store, a sidebar of merged mailboxes and each account's folders as a folding tree, a reader that
opens on the newest message with the older ones as cards, folded quotes and numbered
(clickable) links, filter as you type, a command palette, keys you can
change in
config.toml, andoto open a message's HTML in the browser. See Reading mail. - Archive, trash, star and mark read or unread, alone or as a selection, with undo, on Gmail and
Microsoft 365. See Doing things to mail, including labelling (
l, Gmail) and moving (m). - Writing mail: a new message, a reply, a reply to everyone or a forward, written in your own editor, reviewed in Rail and sent through the account (Gmail or Microsoft 365) with a ten second undo; drafts kept on this machine or saved to the account's Drafts, and a Drafts folder that lists both. See Writing mail.
rail doctor, a single health check for config, store, tokens, sync and the daemon.- An MCP server (
rail mcp) for setting up and inspecting Rail from an AI agent.
Coming
- Next: agent tools for drafts and sends, the last piece of mail work, governed by the per-account
ai_sendpolicy. - Later: search operators, layout options, inline images, display-quality HTML in the terminal.
Pre-1.0 caveats. Config keys, command output and the store's schema may change between releases. The store migrates forward on its own; there is no downgrade path. Rail is tested on Linux and macOS only.
Install
Binaries are available on the GitHub releases page
with SHA-256 checksums: Linux x86_64 and ARM64 (glibc 2.36 or newer) and macOS on Apple Silicon
and Intel (macOS 11 or newer). Up to v0.2.3 only Linux x86_64 was published. Extract the archive
and put rail in a directory on your PATH. GitHub Actions builds each archive and attests where
it came from; check one with gh attestation verify <archive> --repo vitosans/rail. Build from
source on other systems.
Rail builds from source with Cargo. You need Rust 1.88 or newer and a C compiler (SQLite is bundled and compiled in). Supported platforms are Linux and macOS.
git clone https://github.com/vitosans/rail
cd rail
cargo install --locked --path crates/rail
This puts rail in ~/.cargo/bin. To upgrade, pull and run the same command; the first command
of the new version replaces any older daemon still running, including one from an earlier build
of the same version. When an upgrade changes the store, the new daemon migrates it the first time
it starts (rebuilding the search index can take a moment on a large mailbox); until then the mail
screen says the store is not set up.
Quick start
rail # read your mail
rail setup # add an account: guided steps, then a browser login
rail sync # fill the local store and watch progress
rail search invoice # one line per matching thread
rail doctor # check that everything is in order
rail daemon install # keep syncing after you log out and back in
railopens the mail screen (see Reading mail).rail setupopens the setup wizard (runningrailwith no config does the same). Choose a preset your organisation gave you, or set up your own app: the wizard shows the portal steps from Account setup, opens the portal witho, asks for the values it gave you, and logs you in through the browser. It needs an interactive terminal; in scripts userail account addwith flags.rail syncwakes the background sync and shows progress (counts only, never mail) until each account is caught up. Newest mail arrives first, so recent mail is searchable within seconds, but a first sync of a large mailbox takes hours (about 250 messages a minute, under Gmail's quota). Sethistory_daysto sync only recent mail. Ctrl-C stops watching, not the sync.rail search <query>prints date, account, sender and subject for each matching thread, newest first. Gmail's search syntax works for Gmail accounts; Microsoft 365 takesfrom:,subject:,to:,body:and plain words (see Microsoft 365 mail). When a server is busy or refuses the connection (not searched on Gmailornot searched on Microsoft 365says which), it is asked once more after a short wait; the local results still print.rail doctorreports on the config, store, token backend, each account's tokens and sync, the daemon and the login unit.rail daemon installwrites a systemd user unit (Linux) or a launchd agent (macOS) so the daemon starts at login. Without it, the daemon starts on demand whenever arailcommand needs it and keeps running while any account is configured.
Keys
These are the wizard's default keys, the wizard.* actions in the table below; each can be changed.
The wizard exits 0 if at least one account was saved and 1 with setup cancelled; nothing was saved otherwise. Quitting while the browser login is pending cancels that login. An account whose
save had already started is always finished, never left half-written; Rail then says so and exits
0. If you quit after an admin-consent error, Rail prints the admin-consent URL once the terminal is
back so you can forward it to an administrator. When stdout is not a terminal, every way of opening
the wizard prints a hint and exits 2.
After a browser login, Rail verifies that the signed-in mailbox matches the account's address
before saving credentials or adding the account. Selecting a different mailbox fails the login
and preserves existing credentials. Address comparisons ignore case. For Gmail, use the primary
mailbox address (consumer Gmail dot, plus-tag and googlemail.com variants also match); Google
Workspace custom aliases require the primary address. Microsoft 365 also accepts the signed-in
user's principal name and SMTP aliases returned by Graph. Verification uses the existing OAuth
permissions; if the profile cannot be read, finish the login again once the error is resolved.
Every key Rail reads is an action with an id and a default key. Change one in config.toml:
[keys]
"list.filter" = "/"
"reader.open_browser" = "O"
"global.palette" = ":"
A key name is a character (a, O, /), a named key (enter, esc, tab, backtab,
space, up, down, left, right, pageup, pagedown, home, end, f1 to f12),
either with ctrl-, alt- or shift- in front (ctrl-r, shift-tab, shift-down; shift-
goes on a letter, tab, up or down), or two keys with a
space between them for a sequence (g x). A key you set replaces that action's default keys.
Both rail and rail setup refuse to start, naming the file and line and exiting 1, when [keys]
names an action it does not know, a key it cannot read, Ctrl-C (which always quits), or a key
another action of the same view already uses (a key that starts another action's sequence
counts). They check before the terminal changes. A view's own key wins over a global one: q
closes the reader but quits from the list. The defaults count too: J, K, shift-down and
shift-up extend the selection in the list, so a [keys] entry that binds one of them to another
list action ("list.down" = "J", say) stops Rail from starting with that error until you change
the entry or move list.extend_down / list.extend_up to other keys. Likewise h snoozes in the
list and the reader, so the reader's headers moved to H: an older "reader.headers" = "h" entry
stops Rail from starting with [keys] "list.snooze" (h) and "reader.headers" (h) cannot share a key in the reader view until you remove it or bind list.snooze to another key.
The global.*, list.*, sidebar.*, reader.* and compose.* actions below take effect in the
mail screen and the wizard.* ones in the setup wizard. In the mail screen ? lists the keys of the view you
are in, with * after the ones set in config.toml.
Reading mail
rail with an account configured opens the mail screen. It needs an interactive terminal; in a
pipe it prints a hint and exits 2. It reads the local store at once, and connects to the background
daemon (starting it if needed) for sync progress. If the store is not set up yet, the list says so
and points to rail sync; if the daemon cannot be reached, the header says so
(daemon unreachable (Ctrl-r retries)) and Ctrl-r retries.
The thread list shows the unified inbox, newest first, in date groups: TODAY, YESTERDAY,
THIS WEEK (two to six days ago) and OLDER, each name on its own row above its threads; the
group of the top row stays on the first row as you scroll. PgDn and PgUp (Ctrl-d, Ctrl-u)
move a page by the threads on screen: the next page starts with the thread after the last one
shown, the page above ends with the thread before the first one shown, and the cursor keeps its
row, or goes to the page's last thread when the page holds fewer. A group's name never sits alone
on the bottom row. A row shows a dot (● in the
account's colour while the thread has unread mail, a muted · once it is read, ▸ when it is
selected with Space), the account's badge in All inboxes, Starred, Sent and Trash (its
label's first letter on its colour), the people, ★ for starred and 📎 for attachments, the
subject with the message count after it ((3), never cut), and at the right edge the time:
14:05 today and yesterday, Mon 18:20 this week, Oct 03 earlier this year, 2024-10-03
before; mail dated after today is under TODAY with its date, and a date too far off to read
is under TODAY when ahead and OLDER when behind. With time = "12h" (or :time) the times
read 2:05 pm and Mon 6:20 pm, and the time column is two cells wider.
Unread threads are bold.
With icons = "ascii" the dots are *, . and >, ★ is + and 📎 is @.
1 to 9 show one account's inbox, 0 the unified inbox again. The sidebar (shown at 100 columns
and wider; b toggles it, Tab moves into it) has two sections. UNIFIED holds All inboxes,
Starred, Drafts, Sent and Trash, each with every account's mail. ACCOUNTS holds each
account (by its label) with its system folders under the provider's names (Inbox, Sent,
Drafts, Spam and Trash on Gmail; Inbox, Sent Items, Drafts, Archive, Junk Email and Deleted Items
on Microsoft 365); every other folder or label (up to 500 per account) is under
More folders (N), as a tree of its /-separated path (Inbox/Infrastructure/Linode), with
Gmail's categories under Categories. Enter opens the mailbox under the cursor (an account
opens its inbox; More folders and a path that is not a folder itself unfold instead).
Space, ← or → folds or unfolds an account, More folders or a path; accounts start
unfolded and the rest folded, for the session. Counts are unread threads (on a folded path,
everything under it), drafts on Drafts, and none on Sent and Trash.
Each account row has its fold arrow (▾ unfolded, ▸ folded) and a badge, its label's first
letter on the account's colour. Mailboxes have icons (✉ inbox, ★ Starred, ✎ Drafts, ➤
Sent, ✕ Trash, ▣ Archive, ⊘ Spam, 📁 More folders and a path with folders under it)
and their counts on the right with thousands separators. With icons = "ascii" the arrows are
v and > and the icons i, *, d, >, x, a, s and /. While the sidebar does not have
focus the current mailbox's row is in the accent colour; the sidebar scrolls to keep it (or, while the
sidebar has focus, the cursor) in view.
The top of the screen is the header: ▍Rail │ All inboxes · 2 accounts · 22,853 unread
(Starred · 2 accounts · 12 unread, Sent · 2 accounts in the merged mailboxes; an account's
own Sent, Trash or Drafts shows no unread count, work · Sent), the search region
(│ 🔍 / Search, | / Search with icons = "ascii"; the filter shows there once set, and the
Drafts folder has none), and on the right one line for every account's sync: ● Synced 14:47
when all are caught up (the earliest account's time), else the worst state (⟳ Syncing work,
⟳ Backfill 1,200/5,000, ⟳ retrying, work stopped, ! 2 failed, ! work needs login,
! work sync failed, or the daemon's state), then the time of day at the right edge:
● Synced 14:47 │ 15:02 (3:02 pm with time = "12h"; :time changes it at once).
clock = false under [general], or :clock, hides the clock. At split_threshold (140 columns
by default) and wider the sidebar, the list and the reader are boxed, the focused pane in the accent
colour; with icons = "ascii" the boxes are drawn with +, - and |, and an unboxed header
reads |Rail | All inboxes …. At split_threshold and wider, and at least 12 rows high, the header
has a box of its own too (never in the accent colour), and inside it the row reads
│ Rail │ All inboxes … ● Synced 14:47 │ 15:02 │, without the ▍ bar (| Rail | … with
icons = "ascii"). When the scope and the summary do not both fit, the summary and the clock stay
whole and the scope is cut. While a filter is set or being typed, the search slot keeps at least 24
cells (at 60 columns and wider; less only when the summary itself leaves no room): the scope is
cut first. Under 60 columns the clock and the search region go, and under 40 the summary.
The status bar at the bottom names the keys of the pane you are
in (Enter open j/k move Tab sidebar r reply … ? help, with the keys your config.toml sets)
and shows each account's state on the right. Narrower than split_threshold it is one row; at
split_threshold and wider (and at least 12 rows high) it takes three rows and each key is drawn in
its own box, with no frame around the bar (+, - and | with icons = "ascii"):
┌───────┐ ┌─────┐ ┌─────┐ ┌───┐
│ Enter │ open │ j/k │ move │ Tab │ sidebar │ ? │ help ● work ✓ · ● home ✓
└───────┘ └─────┘ └─────┘ └───┘
:hints (or hints = false under [general]) turns the hints off: the bar is one row of notices
and account states at every width, and ? still lists the keys.
Enter opens a thread. At split_threshold columns (140 by default) and wider the reader opens
beside the list; narrower, it covers the screen. q or Esc closes it. The reader starts with
the thread's title block: the subject; the newest message's sender, its time (04:32 today,
Thu 08:55 yesterday and this week, Oct 03 or 2024-10-03 before) and the account's badge
and label (Dana Reyes 04:32 W WORK); its recipients by name and the thread's size
(To: Vito, Tom / 4 messages, or / 4 messages when it names none); then a rule. The
newest message is open under it. The older messages follow as cards between rules, oldest first
(thread_order = "newest" under [general] runs them newest first): a card is the message's
number in the thread in a grey box, its sender (bold while unread, ★ when starred) and its time
at the right edge (▸ 2 Tom … Mon 22:11), over a line of its text. When a long thread's
oldest messages are not shown, [5 older messages of this thread are not shown] stands where they
would be. j/k move the cursor, n/p jump between the newest message and each card, Tab (or
Enter on a card, the subject or a preview) opens or closes the message under the cursor, and
Enter on [n quoted lines] shows the quoted text. H (h before snooze took it) shows or
hides, for the session, the headers of every open message: From:, To: and Cc: with addresses,
Date: Fri 10 Oct 2025 12:40, and Subject: when it differs from the thread's. With
icons = "ascii" the arrows are > and v and the rules -.
HTML mail is shown as Rail's plain-text rendering, with each link as a numbered marker such as
[1]; in terminals that support hyperlinks (iTerm2, WezTerm, kitty, Ghostty, VS Code, Windows
Terminal, Hyper, foot, Alacritty, GNOME Terminal and other VTE terminals; not inside tmux or
screen) the marker is clickable; while a box such as help or a confirmation is open the markers
are plain text. g x opens a link: the marker on the cursor line, or the number you type when the
line has none or several. The confirmation shows where the link really goes before the address
(cut if it is long): the host (an international or look-alike host in its ASCII xn-- form) or,
for mailto, the recipients (To, Cc and Bcc, including those added by the link's
parameters): up to five, then a count of the rest. It warns when the address has text before an
@ that could pass for a host (https://[email protected] goes to evil.example), when a
mailto link adds recipients, or when a mailto link carries a parameter other than to, cc,
bcc, subject and body (some mail clients have acted on attach=); the warning names up to
five such parameters. Rail opens only http, https and mailto links of at most 2,048 bytes.
a lists the thread's attachments (name, type, size) with a cursor: j and k move it, and each
message's rows sit under a MESSAGE <n> heading when more than one message carries attachments.
Enter or o opens the highlighted one with the program your system uses for it — Rail asks once
a session before the first open, and only for the common document and image types it hands over;
anything else is saved instead and says so. Office documents (Word, Excel, PowerPoint and their
OpenDocument kin) and RTF are among them, so the question warns that they can hold macros or links
that run or fetch content when opened. A type is the sender's claim, and your system's opener
may judge a file by its contents instead, so before writing the copy Rail checks that the file
starts the way its type does (%PDF- for a PDF, the PNG, JPEG, GIF, WebP, BMP, TIFF or HEIC
signature for an image, a ZIP whose first part is an Office one for .docx and its kin (so a
Java archive renamed .docx is refused), a ZIP naming its own type first for
.odt, .ods and .odp, the old Office header for .doc, .xls and .ppt, {\rtf for RTF).
Plain text, CSV and TSV may be in any 8-bit encoding (UTF-8, or Latin-1 from a spreadsheet), but
not hold a NUL byte, start (past any byte-order marks and white space) with markup, a #!
script line or a PDF or PostScript header, or have an HTML, SVG or XML tag start in their first
64 KiB, whatever its case or the white space inside it. A file that fails is not opened — a web page
labelled as a picture would otherwise open in your browser — and is saved instead, with a line
that says so. s saves the highlighted attachment, S saves every
attachment of its message, and Esc, q or a closes the list. From the reader itself, w
saves the cursor message's attachments without opening the list. Saves land in [general] attachment_dir (below), your download directory by default, and never replace a file you have: a
name already there becomes 2-name.pdf.
o opens the message's original HTML in your browser, for a Microsoft 365 account too (the HTML
Rail stored). Rail writes it to a private (0600) file in
the open folder of its cache directory (~/.cache/rail/open/ on Linux; RAIL_CACHE_DIR
overrides the cache directory) with scripts, frames and connections blocked, and removes the file
when you close the reader, quit Rail, close the terminal window or stop Rail with kill. Files a
crashed session left behind are removed the next time the mail screen starts, once they are a day
old. The first time in a session Rail asks first, because
remote images, styles, fonts and media in the message load in the browser, which can tell the
sender you opened it; Rail itself never loads them. A message whose body is not stored yet is
fetched first. A message with no HTML part says so: its text is all there is.
Two limits of o. Firefox installed as a snap (the default on Ubuntu) cannot read hidden folders
such as ~/.cache, so it shows a file-not-found page: set RAIL_CACHE_DIR to a folder it can read
(for example RAIL_CACHE_DIR=$HOME/rail-cache) or use a browser that is not a snap. And a
<meta http-equiv="refresh"> tag in the message can still send the page to another address once
it is open; the blocking above cannot stop it, so close the tab if the page moves.
Bodies of recent mail (body_cache_days) are already in the store. An older message shows
fetching… while the daemon fetches its body from its account's server (Gmail or Microsoft 365);
the rest of the thread stays readable, and the text appears when it lands. If the fetch fails the
message says why.
/ opens the filter line in the header's search slot and filters the list as you type. The
slot is not drawn when the terminal is narrower than 60 columns, or, before a filter is set, when
fewer than 8 cells are left for it after the counts and the summary (with a filter set, only when
the summary itself leaves no room); the line then opens in the status bar.
Plain words search subjects, the names and addresses of senders and recipients (To, Cc
and Bcc) and stored bodies; from:, to: (also Cc and Bcc), subject:, has:attachment,
is:unread, is:starred, before:YYYY-MM-DD, after:YYYY-MM-DD (local dates, by when the mail
arrived) and account:<name> narrow it; quote values with spaces (from:"Dana Reyes"). A thread
matches when one of its messages matches all of them. Anything else is searched as words, and the
header's search slot shows how Rail read your filter (/ is:unread · words: form:dana), so a typo
such as form:dana shows up as a word. Enter keeps the filter; Esc clears it. The filter
searches the local store only (rail search also asks each account's server).
S saves the filter in force under a name, so you never type it again: the prompt asks for a
name of 1 to 40 letters, digits, - or _, Enter saves it and Esc cancels. A filter that
narrows nothing — only characters the filter ignores, such as * ( ) — is not saved; Rail says
That filter narrows nothing; add a condition to save it. A saved search
then behaves like a mailbox — its own row in the sidebar under SEARCHES, an unread count, a
Search: <name> palette entry, a header title, paging and Esc — and lists every folder of
every account narrowed by that filter, so it finds archived mail an inbox view would not. /
narrows a saved search further, and Esc on the reopened filter line clears only that live
filter; it never removes a saved search. S inside an open saved search saves only what you
typed after /, not that text joined to the open search's query: to save the narrower search,
type the whole filter. An account:<name> inside a saved query still narrows
to that account. A saved before:/after: keeps the date you typed, so a saved date ages: a
search for after:2026-01-01 means that one day for ever, not "since a year ago". r and d
always act on the search the prompt or the question named: if the list changed while one of them
was open — an earlier save or removal was answered underneath it — Rail says
The saved searches changed; try again. and changes nothing. Rail keeps at most 20 saved
searches, in [[saved_searches]] in config.toml, where they are yours to read, edit and share.
On a large mailbox each counted search costs a query of its own on every sidebar refresh, so
:search-counts turns the counts off.
: opens the command palette: type a few letters of a key action for the current view, an account,
a label or a merged mailbox (Mailbox: Starred, Mailbox: Drafts, Mailbox: Sent,
Mailbox: Trash: that mailbox of every account together, newest first) and press Enter; the
key bound to each command is shown beside it. A label is listed as
Label: AXON · Inbox/Infrastructure/Linode: the account's label and the folder's whole path,
folded away in the sidebar or not. Each saved search is listed as Search: needs-reply, and
beside it a Rename search: needs-reply and a Remove search: needs-reply entry, which open
the same prompt and the same question as r and d on its sidebar row.
:errors lists the session's recent errors; every sync pass
that failed for good (one a retry will not mend) is listed there once, after its account's name,
while :errors still holds it. While one of them is an action Gmail refused, j/k or ↓/↑
highlight an entry and Enter sends that action again. :hints turns the key hints off
(hints: off), or back on (the hints are the confirmation: no notice), and saves the choice
(hints = false under [general]; turning them on removes the key); a save that fails says
Could not save the hints setting: … and keeps the choice for this session. :clock hides the
header's clock (clock: off), or shows it again (clock: on), and saves the choice
(clock = false under [general]; showing it removes the key); a save that fails says
Could not save the clock setting: … and keeps the choice for this session. :time switches every
time of day on screen (the list, ● Synced 14:47, the header's clock, the Drafts list, :errors,
the reader's 04:32 and its Date: Fri 10 Oct 2025 12:40 lines) between 24-hour and 12-hour
(2:47 pm, 4:32 am, Date: Fri 10 Oct 2025 12:40 pm) and saves the choice (time = "12h" under
[general]; 24-hour removes the key); a save that fails says Could not save the time setting: …
and keeps the choice for this session. :thread-order turns the reader's older messages round
(newest first, or oldest first again), in the open thread too, says thread order: newest or
thread order: oldest, and saves the choice (thread_order = "newest" under [general]; oldest
first removes the key); a save that fails says Could not save the thread order: … and keeps the
choice for this session.
:dim-read cycles how read threads look in the list: dot (the default) keeps today's small
muted dot, subject also dims the subject, and all dims the sender, subject, count and time.
Dimming uses the current theme's muted colour. Unread threads stay bold; the cursor highlight,
account badges, selection marks and action errors keep their colours. The command says
dim read: dot, dim read: subject or dim read: all and saves dim_read under [general];
returning to dot removes the key. A save that fails says
Could not save the read dimming setting: … and keeps the choice for this session.
save-search names the filter in force and saves it, exactly as S does; it is offered only
while a filter is set. rename-search <name> and remove-search <name> rename or remove one
saved search — one entry each, per search, so the palette says which — and are offered only once
there is one. search-counts hides the unread count beside each saved search
(saved-search counts: off), or shows it again (saved-search counts: on), and saves the choice
(saved_search_counts = false under [general]; showing them removes the key); a save that
fails says Could not save the saved-search counts setting: … and keeps the choice for this
session.
? shows the keys of the view you are in, marking with * the ones set in
config.toml; ? again (or whatever global.help is bound to), Esc or q closes it. Help and
:errors scroll with j/k or ↓/↑; a ↓ more or ↑ more line (v more and ^ more with
icons = "ascii") says there is more beyond the box.
The status bar shows, on the right, each account's sync state (✓ caught up, ⟳ syncing, with
a count during the first sync, retrying, stopped, and ! for needs login or
sync failed). After the state come sending… while a message is on its way,
saving to Gmail… (saving to Microsoft 365…) while a draft is saved, 2 queued for actions on
their way to the account's server and
1 failed for actions it refused; see
Doing things to mail.
With threads selected, the header's left part ends with 3 selected. Whether the daemon can be
reached shows in the header's summary (connecting to the daemon…,
daemon unreachable (Ctrl-r retries), or daemon reachable again (Ctrl-r resumes live updates)
once a message fetch gets through after the connection was lost); notices show on the status bar,
in place of the key hints. When the daemon cannot be reached the list still reads from the store;
Ctrl-r reconnects (and starts a sync) and resumes live updates. A store read that fails says so
where it was shown (the list, the top of the sidebar, or the reader's subject line) and in the status
bar; :errors keeps it.
Themes
Rail paints itself from a colour scheme of its own, on any terminal that shows 24-bit colour
(COLORTERM is truecolor or 24bit). :theme lists the 769 schemes of the
iTerm2-Color-Schemes collection (MIT),
Rail default first; letters filter, ↓/↑ and Ctrl-n/Ctrl-p move, and the screen takes
each scheme as the highlight reaches it. Enter keeps it and writes theme = "Name" under
[general] in config.toml (choosing Rail default removes the key); Esc puts the previous
look back. :accent does the same for the accent (the focused pane's border, box borders, titles
and headings, what you type in the palette and the filter, the sync spinner): scheme default
or one of the scheme's sixteen palette slots, saved as accent = N. The other panes' borders take
the muted colour
(palette slot 8 of a scheme) whatever the accent. The status bar confirms theme: Dracula or
accent: 5 magenta; a save that fails says Could not save the theme: … (:errors keeps it)
and keeps the look for this session.
Without truecolor Rail keeps the terminal's own colours and the status bar says
theme needs a truecolor terminal when a theme is configured and when :theme opens. :theme
then does not preview (its hint row says no preview without truecolor) and Enter still saves
the choice; :accent still previews a palette slot, which uses the terminal's own colours there.
Inside tmux, COLORTERM must reach the inner shell (see Troubleshooting). An account's own
color in config.toml is kept whatever the theme.
Doing things to mail
From the thread list or the reader: e archives, d moves to the account's trash (Rail asks
first, Trash "subject"? or Trash 3 threads?, and says what the trash does: Gmail keeps trashed
mail in its Trash for 30 days, and on Microsoft 365 it Moves to Deleted Items.; Rail never
deletes mail for good), s stars or unstars (it stars unless every thread is already
starred), u marks read or unread (it marks read if any thread is unread). An action applies to
the whole thread, as in Gmail. Over 50 threads (bulk_confirm under [general]) e, s and u
ask first too, naming the count, for example Archive 1,234 threads?; d always asks. The list
changes at once; the daemon sends the change to the account's server (Gmail or
Microsoft 365) in the background, so it also goes through if you quit Rail.
l opens the account's labels: type to filter, Space ticks or unticks, Enter applies (labels
you ticked are added, labels you unticked are removed; for a selection, the ticks start from every
label any selected thread carries), Esc cancels. Down/Up (or Ctrl-n/Ctrl-p) move,
because letters filter. On Gmail, m opens the same list to move: Enter adds the highlighted
label and archives the thread. Gmail's system folders (Inbox, Sent, Trash and the like) are not in
the list, and Rail does not create labels. A selection that spans two accounts cannot be labelled
or moved at once; Rail says Labels belong to one account: select threads from one account.
Space selects the thread under the cursor (a ▸ replaces its dot, > with icons = "ascii")
and moves down; the header counts the selection (3 selected). With a selection, an action
applies to every selected thread, then the selection clears; Esc clears it. The selection stays
while you change the filter or the mailbox. In the reader, the keys act on the open thread, not on
the selection, which stays as it was (Space still pages down, and selecting is for the list);
archive, trash and move close the reader.
* a selects every thread this mailbox and filter match — newest first, at most 10,000, and the
header then reads 10,000 selected (more match); when nothing matches it says Nothing matches.
and selects nothing. * f puts the sender of the thread under the cursor into the filter line as
from:<address> and selects every thread with any message from that address, as the same from:
filter would show them: in the filter line that form matches that address exactly, where a plain
from:dana matches every thread whose sender's name or address contains dana. The sender is
that of the thread's newest message from someone else: your own messages (from the account's
address, or in its Sent or Drafts) are passed over, so a thread you answered last selects by the
person you answered. A thread with no such message with a readable sender, or one whose address
the filter cannot carry, says This thread has no sender to select by. and changes nothing. If
you change the mailbox or the filter before * a or * f has answered, its answer is dropped and
nothing is selected.
v starts a range at the thread under the cursor: the bar says range from here, and while the
range is open its first key hint is Esc range. Move as usual, then v or Esc ends the range
and keeps every thread it covered; Esc again clears the selection, as before. If the thread you
started from has left the list by then, Rail says The range start is no longer in the list. and
selects nothing.
J or Shift-↓ extends the selection down from the thread under the cursor, and K or Shift-↑
up: the first press selects that thread and the next, and each further press one more. Going
back toward where you started unselects what the extension added; threads you had selected before
stay selected. Any other key ends the extension and keeps what it selected, so J after a move
starts again from the new place. A terminal that does not report Shift with the arrows still has
J and K; elsewhere in Rail, the Drafts folder included, a Shift-arrow moves like the plain
arrow, and there J and K move too. While v has a range open, J, K and the Shift-arrows
only move. If a sync reads the list again while you extend and the thread you started from leaves
it, the extension carries on from where the cursor was. A selection holds at most 10,000 threads:
an extension or a v range it stops says The selection holds at most 10,000 threads.
A bulk action goes out as one request per 200 threads per account, and one z takes the whole set
back, reporting undone once; an undo that could take only part of it back says undone in part: some were already sent. When one of the requests fails, its threads come back to the selection and
its error stays on the status bar while the others answer.
After an action the status bar says what happened, in place of the key hints, for example archived 3 · z undoes. For ten
seconds z takes it back; afterwards it says Too late to undo., and with nothing done,
Nothing to undo. If the daemon has not answered yet, z says undoing… and finishes when it
does. Only the most recent action can be undone, and marking a thread read by opening it has no
undo. z works anywhere in the mail screen. An earlier action whose answer comes after a newer one
still shows its error if it failed (its selected threads come back to the selection) and is still
undone if z was pressed for it; that undo says only too late to undo when it came too late.
Queued moves, label changes and undo keep targeting the original folder in the original account across syncs. If that folder has been deleted, Rail does not substitute another folder.
Opening an unread thread marks it read at once in the reader; the list row and the unread counts
follow a moment later. u marks it unread again.
Each account in the status bar shows actions on their way to its server (2 queued) and actions
the server refused (1 failed). The daemon sends the actions on one thread in the order you made them;
an action waiting to be tried again holds back only the later actions on its own threads. When
Gmail cannot be reached it tries again after 1, then 5, then 15 minutes, then every 15 minutes,
and gives up on an action after 20 attempts. While an account needs a login, its actions wait and
are sent after the login, and an action still waiting when Rail starts again is sent. If recording
a server result in the local store fails, that account's queue waits while the daemon retries the
store write after one second, doubling the wait up to one minute. It keeps the server result and
does not repeat the provider action; the queue continues once the write succeeds. When Gmail
refuses an action for good (the thread or the label no longer exists), Rail undoes it in the
list, shows "Could not archive: …" on the thread's row, and keeps the error in :errors, where
Enter on it sends the action again. Refusals are listed in :errors again whenever Rail
connects to the daemon, so one that happened while Rail was closed or disconnected is not lost.
A later sync that brings Gmail's state of that thread clears the refusal and its 1 failed (a
snoozed thread's failed return excepted: see Snooze).
On a Microsoft 365 account the same keys act through Outlook's folders, where a message lives in
one folder. e moves the thread's Inbox messages to Archive; on a mailbox with no Archive folder
it says This mailbox has no Archive folder; press m to move. (Rail never makes one). d moves
the thread's messages to Deleted Items (the prompt says Moves to Deleted Items.). m lists
Inbox, Archive, Junk Email and your own folders, and moves the thread's messages to the one you
pick. Archive, trash and move leave your own messages in Sent Items and Drafts where they are.
s flags the newest message, or clears every flag of a flagged thread; u marks every unread
message read, or the newest message unread. l answers
Outlook uses folders, not labels: press m to move. A selection may hold threads of both kinds:
each account gets its own change, and l is refused for the whole selection. m is refused
for a selection spanning accounts: Labels belong to one account: select threads from one account. When Outlook
refuses some messages of a thread (one deleted meanwhile, say), the others still change and the
refusals are logged; only when it refuses every message does the action fail. Opening an unread
thread marks it read in Outlook (the status bar shows 1 queued meanwhile).
Snooze
h snoozes the thread under the cursor, the selection or the open thread (which closes): Rail
archives it now and puts it back in the inbox at the time you choose, unread, first under TODAY.
The chooser offers Later today (18:00, before 17:00), Tomorrow (08:00), This weekend
(Saturday 08:00, Monday to Friday) and Next week (Monday 08:00), each with its time; a choice
that falls at the time of an earlier one is left out (on a Friday, this weekend is tomorrow).
There is no calendar to click through: type a time instead, in numbers or in words.
- A time of day:
18:00,6pm,6:30 pm,noon(today, or tomorrow once it has passed). - A span:
3 days,in 2 hours,an hour,90 min,1 week,1 month,+3h. - A day:
tomorrow,tonight,later today,this weekend,next week,fri,next monday. - A date:
nov 3,3 nov 2026,november 3rd,2026-11-03; without a year, a date that has passed this year means next year's. - A day with a time, in either order:
tomorrow 9am,9am fri,nov 3 at 14:00.
24-hour and am/pm times both work whatever time is set to. A day without a time comes back at
08:00, tonight at 20:00 and later today at 18:00. What you type is offered first as At with
its time; typing a choice's name (tomorrow) picks that choice. Text Rail cannot read says Try 6pm, tomorrow 9am, 2 days, nov 3 or fri., a time that has passed That time has passed., one
less than about a minute ahead That is too soon., and one more than a year ahead That is more than a year away. Enter checks the time against the clock once more, so one that became too
soon while the chooser was open is refused there and nothing is sent. The last time you typed
is offered first next time as Last used, read again against the clock (2 days counts from
when you open the chooser), unless a choice already offers that time; Rail keeps it in the state directory
(snooze-last), so it survives a restart. Enter snoozes, Esc cancels, Down/Up move.
The status bar says Snoozed until Mon 08:00 · z undoes, and for ten seconds z takes the snooze
back, as with archive. On a thread that is already snoozed the chooser's title says until when;
choosing a time moves the snooze, and its first row, Return to inbox now, puts the thread back
at once (returned 1 to the inbox). While anything is snoozed, Snoozed is listed under UNIFIED in
the sidebar with the number of snoozed threads (◷, or ^ with icons = "ascii"); : and
Mailbox: Snoozed open it at any time, and an empty one says Nothing is snoozed. A thread whose
snoozed mail was all trashed, moved or put back in the inbox since has nothing left to bring back,
so it is neither listed nor offered Return to inbox now. The reader
shows Snoozed until Mon 13 Oct 2025 08:00 under a snoozed thread's recipients. Drafts cannot be
snoozed (Not available on drafts).
A selection is snoozed as one. The chooser's title counts it (Snooze 3 threads until, with
Return to inbox now first when some of it is snoozed), and the snooze takes the threads that
have mail in the inbox, or are snoozed already, and leaves the others where they are; the status
bar counts both (Snoozed 118 until Mon 08:00 · z undoes · 2 not in the inbox). Only a snooze
with none to take is refused (Only a thread in the inbox can be snoozed.). Return to inbox now
counts what came back as its requests answer (returned 250 to the inbox), and a request that
fails says why and gives its threads back to the selection.
New mail in the thread brings it back early; your own reply, which lands in Sent, does not, and
neither does a message of the snooze put back in the inbox from another device (snoozing the
thread again then archives it with the rest). When threads come back the status bar says Back from snooze: <subject> or 3 threads back from snooze.
Neither Gmail nor Microsoft 365 offers a snooze Rail can use, so Rail keeps the time itself: other
devices show the thread archived until it comes back, and it comes back while the daemon runs (see
The background daemon). The return puts back exactly the messages the
snooze took out of the inbox: on Gmail it adds INBOX to them (one you trashed or reported as
spam meanwhile stays there), and on Microsoft 365 it moves them from Archive back to Inbox (one
you moved to another folder meanwhile stays there). If the server refuses the archive, the snooze fails with it (Could not archive: …, and
Enter in :errors sends it again); snoozing the thread again starts afresh. A return the server
cannot take right away is tried again like any action; one it refuses, or that gives up after 20
attempts, leaves the thread archived and stays in :errors (Could not return to the inbox: …)
through later syncs, where Enter sends it again; a retry leaves mail you trashed or reported as
spam since where it is. It leaves :errors once it is sent, once the thread is back in the inbox
by other means, once none of its messages is still archived (all trashed, reported or filed
elsewhere), or when you archive, trash or move the thread in Rail. So a return the server keeps
refusing stays until you send it again with Enter in :errors and it goes through, or you
archive, trash or move the thread in Rail to give it up. Removing an account
leaves its snoozed threads archived, since nothing syncs that account any more.
Writing mail
From the thread list or the reader: c writes a new message, r replies to the latest message of
the thread, R replies to everyone and f forwards it. In the list they act on the thread under
the cursor; in the reader, on the open thread. In the unified inbox with more than one account,
c first asks which account to write from; elsewhere it writes from the account you are looking
at. One message is written at a time (Finish or close the message being written first.). A
reply goes to the message's Reply-To when it has one, otherwise to its sender; R adds the
other recipients except you, keeping the old Cc in Cc. A reply starts with Re: once and the
quoted text under On <date>, <name> wrote:; a forward starts with Fwd:, no recipients, and the
original under a Forwarded message line, with its attachments. The links the quoted or
forwarded text marks follow it after a blank line, one per marker: [1] https://…, or
[2] (link not included) for an address Rail would not open. If the message's text is not in
the store yet, Rail fetches it first (fetching the message…). Writing from a Microsoft 365
account works the same way; Microsoft 365 mail says what differs.
A new message starts with two prompts on the bottom row. To: completes from the addresses
your mail has taught Rail: type a few letters and matching addresses appear above, none
highlighted; Tab and Shift-Tab (or ↓ and ↑) move through them. Enter takes the
highlighted one, or the text you typed when none is, and a comma takes the highlighted one and
starts the next recipient (with none highlighted it is typed as a comma). Then Subject:.
Each address is checked as you press Enter and the prompt stays open with the reason when one
is wrong. A recipient is a plain user@domain or Name <user@domain>; a quoted name with a comma
("Reyes, Dana" <[email protected]>) is one recipient. Esc in either prompt cancels the
message; nothing is saved. A reply or forward skips the prompts and opens the editor at once.
The editor. Rail runs general.editor, else $VISUAL, else $EDITOR, else vi, on a
file in its cache folder (compose/<draft>.eml, mode 0600). The command is split like a shell
command line (quotes work) but no shell runs, so pipes and ~ are not interpreted. An editor that
returns at once needs its wait flag: use editor = "code --wait". While the editor runs Rail
steps aside and ignores Ctrl-C and Ctrl-\; it takes the terminal back when the editor exits.
The file holds four headers, a blank line and the body:
To: Dana Reyes <[email protected]>
Cc:
Bcc:
Subject: Lunch
Are you free on Thursday?
To, Cc and Bcc take comma-separated recipients; a header is one line (a line starting with
a space continues the one before). Any other header, or a line that is not Name: value, is
refused: Rail names the line and opens the editor again on the same text. Rail also accepts a file
the editor saved with Windows line endings (CRLF) and keeps the message as plain lines. If the
editor exits with a non-zero status Rail asks Keep what you wrote?: yes saves the text, no keeps
the draft as it was. If the editor cannot start Rail says cannot run … and the review opens with the text it
had. The file is kept until a save succeeds, and also when the editor cannot run or its file
cannot be read back. If Rail stops before that (a crash, a closed terminal), the next editor round
for that draft opens on the file as it was left, headers included (so changes made with t after
reopening the draft are replaced by the file's headers), and says
opened the text from your last editor session. Sending or discarding the draft without opening
the editor uses the stored text, and the file stays until it is removed after seven days. Quitting
that editor with an error and answering no to Keep what you wrote? keeps the draft as it was. A
new message is saved before its first editor round, so it is recovered like any other draft. A
new.eml (left when that first save failed) is never reused for another message; it is removed
after seven days.
The review. When the editor returns, Rail shows the message as it will go: From, To, Cc
and Bcc (when used), Subject, then the attachments with their count and total size, then the
body, with long runs of quoted lines folded to [n quoted lines]. A recipient with an
international domain is followed by the domain's ASCII form, as xn-- addresses are what
the mail system sees. The status bar names the keys:
These are the compose.* actions in the key table and can be changed. While a t or a prompt
is open the status bar shows the prompt's keys instead: Enter accept Tab complete Esc cancel.
y is refused with a reason when there is no recipient (Add a recipient first), while a save
is pending, or after a save failed. If a save failed, q asks Discard the unsaved changes? and
then says unsaved changes discarded; a message closed before anything was saved says
The message was not saved.
Sending. y hands the message to the daemon, which sends it ten seconds later. The status
bar says sent · z undoes and z takes it back inside that window: the review opens again with
the message as it was. After the ten seconds the account shows sending… and the message goes
through Gmail like the actions in Doing things to mail. Closing the
review with q before the daemon has the message does not stop the send: its sent · z undoes
(or Could not send) still comes, even if another message is open by then. z then cancels the
send but cannot reopen its review; the draft stays in Drafts, to open from there. Until
the last message is queued, y and s on any message are refused (Wait for the last message to be queued.), and so are e, a and x on the review being sent or saved to the account's
Drafts. A draft whose send or save (s) is on its way does not open from the Drafts folder or
:drafts (This draft is being sent; wait for it to be queued., or
This draft is being saved to Gmail; wait a moment., or
This draft is being saved to Microsoft 365; wait a moment. on a Microsoft 365 account); other
drafts do. If the connection to the daemon is lost first, Rail cannot tell whether the message
was queued and says Could not confirm the send: … · check Sent and Drafts
(Could not confirm the save to Gmail: … · check Drafts for s, or
Could not confirm the save to Microsoft 365: … · check Drafts). If it is in Sent, it went; if
it is still in Drafts, open it and press y again: that is safe, since the daemon refuses a draft
it already has queued (this draft is already being sent) and a sent draft is gone. A message is
never sent twice: before any later attempt (a retry, or a start after the daemon stopped
mid-send) the daemon asks Gmail whether a message with that Message-ID has the SENT label and,
if so, counts it as sent. An unsent copy in Gmail's Drafts does not count. A reply whose
conversation was deleted in Gmail meanwhile goes out on a conversation of its own. When Gmail
refuses a message for good, the review opens again with the error
(Could not send: Gmail refused the request (HTTP 400)) and the failure is listed in :errors.
Drafts. Every editor round, header change and attachment change saves the message in the
local store, so a crash or a closed terminal loses nothing. A reply whose original message has
since left the store keeps its threading. A draft stays on this computer until you press s on
its review: Rail saves any text not stored yet, saves the draft to Gmail's Drafts ten seconds
later (Outlook's Drafts for a Microsoft 365 account), closes the review and says
saved to Gmail · z undoes (saved to Microsoft 365 · z undoes). z inside that window cancels
it (kept on this computer). The review closes after s, so pressing s again means opening the
draft again from the Drafts folder or :drafts; done inside the ten seconds, it makes one save
with the newest text, and z after it cancels that save. s is refused when the message holds
nothing at all. The status bar shows saving to Gmail… (saving to Microsoft 365…) while a save
is on its way. A save the provider refuses on the spot keeps the review open
(Could not save to Gmail: …, or Could not save to Microsoft 365: …); one that fails later is
listed in :errors (Could not save the draft to Gmail: …, or
Could not save the draft to Microsoft 365: …), where Enter retries it. The first save
creates the draft in Gmail and the next ones update it, so the draft keeps one place in Gmail's
Drafts and opens on your phone. Rail's version wins: if the same draft was also edited in Gmail,
the next s overwrites that edit. Sending a draft that was saved to Gmail deletes its Gmail copy
once the message is sent; if that delete fails it is listed in :errors, where Enter retries it.
Creating the Gmail draft is tried once and never repeated inside one attempt, as a send is. When that attempt may have reached Gmail but its answer was lost, the retry later can leave a second copy of the draft in Gmail's Drafts. Delete the extra one there; updating a draft, deleting it and listing drafts are retried freely and cannot do this.
The Drafts folder. Each account's Drafts in the sidebar lists its drafts in one pane
(Drafts under UNIFIED lists every account's), newest first, up to 500: the drafts saved on this
computer and the Gmail drafts written elsewhere (on your phone, in the browser). A row shows the
date, the recipients and the subject, like a thread; a draft kept only on this computer says
(local) where the recipients go and has ✎ (* with ASCII icons) before its subject, and a
draft saved to Gmail is listed once. Enter opens a draft at its review. A Gmail draft is
fetched first (fetching the draft…), together with its
attachments, and from then on is a draft of this computer too, its Gmail copy updated by s.
A Gmail draft Rail already holds that was edited elsewhere since is listed again as a Gmail row;
opening it opens Rail's copy, as Rail's version wins. d asks Discard this draft? (y/n); yes
deletes it, and its copy in the account's Drafts with it (Outlook moves it to Deleted Items).
While a save to Gmail is still on its way the discard is refused
(draft: saving to Gmail, try again in a moment, or
draft: saving to Microsoft 365, try again in a moment): try again once it has landed. A Gmail
draft that was deleted elsewhere before you opened or discarded it says
Gmail no longer has this draft; one that cannot be fetched says Could not open the draft: … or
Could not discard the draft: … with the reason. Archive, star, read, labels, move,
select, selecting all or by sender, the range, reply, forward and the filter
(e, s, u, l, m, space, * a, * f, v, r, R, f, /) say
Not available on drafts; Esc does nothing there and c writes a new message as everywhere.
The search slot is empty in this folder: the pane is not filtered. The pane is read again whenever
sync or the outbox
changes something. :drafts (Open a draft in the palette) lists the same rows for the account
in view; in the unified inbox and the merged mailboxes it merges every account's, and Enter
opens one the same way.
A Microsoft 365 account's rows are its Outlook drafts and behave the same way: Enter imports
one with its attached files, d says It is deleted from Outlook's Drafts. and deletes it there,
and one deleted in Outlook first says Microsoft 365 no longer has this draft.
Files of an imported draft. The attachments of a Gmail draft are fetched once, when you open
it, into private files the daemon keeps in the draft-files folder of the data directory
(folders 0700, files 0600). Later saves and the send use those files, not Gmail's copy. Discarding
the draft removes its files, and so does a completed send; when the daemon starts it removes every
file no draft refers to, which catches any a removal could not.
Limits. A message has at most 100 recipients, 50 attachments and 25 MiB in all (Gmail's
limit, kept for Microsoft 365), 10 MiB of text and a one-line subject. Attachments count as they are
sent, a third larger
than the files (so about 18 MiB of files fit); the review's total shows the files' own sizes, and
a file that would take the message over is refused when you attach it, naming the size the
message would have. File names are cleaned before they are sent. Attachments come from files you
name by path; a forward carries the original's attachments along, and they count in the total. Only
regular files can be attached, and
they are read when the message goes out: one that was moved or deleted in the meantime fails the
send (Could not send: could not read an attachment of this message) and the review opens again.
Your address and signature. From: is the account's address, with from_name before it when
set. A signature file is added under -- to every new message, reply and forward. Both are
described under [[accounts]].
Account setup
Rail ships no OAuth client of its own: you register one per provider once (or use a preset from your organisation), then log in. The wizard walks you through the same steps shown here.
Gmail / Google Workspace
-
In Google Cloud Console create a project and enable the Gmail API.
-
OAuth consent screen: External. Publishing status must be "In production", even unverified. An app left in "Testing" has its refresh tokens expire after 7 days, and Rail will keep asking you to log in.
-
Credentials: create an OAuth client ID of type Desktop app and copy the client id and secret. A desktop-app secret is not actually secret; keeping it in
config.tomlis fine (or keep it in 1Password). -
Log in with
rail setup, or:rail account add gmail --address [email protected] \ --client-id XXXX.apps.googleusercontent.com --client-secret GOCSPX-...A browser opens for consent. Google warns that your own app is unverified; continue.
The same steps with gcloud. gcloud can create the project and enable the API, but it cannot
create a desktop OAuth client, so steps 2 and 3 stay in the browser. Pick a project id: 6 to 30
characters, lowercase letters, digits and hyphens, starting with a letter and not ending with a
hyphen:
gcloud auth login
gcloud projects create <project-id>
gcloud config set project <project-id>
gcloud services enable gmail.googleapis.com
Then, in the browser:
- the consent screen at
https://console.cloud.google.com/apis/credentials/consent?project=<project-id>: External, then publish ("In production"); - the credentials page at
https://console.cloud.google.com/apis/credentials?project=<project-id>: Create credentials, OAuth client ID, Desktop app, and download the JSON file.
An AI agent connected over MCP can walk you through exactly these steps (see Using Rail from an AI agent).
Microsoft 365 / Outlook
-
In Azure Portal, App registrations > New. Supported account types: pick what matches your mailbox (work/school, personal, or both). Redirect URI: platform Mobile and desktop applications, value
http://127.0.0.1. Rail listens onhttp://127.0.0.1:<random port>, and Azure matches any port for a loopback redirect. If the portal refuses anhttp://127.0.0.1URI, add it by editing the app's Manifest (replyUrlsWithType) instead. -
Under Authentication, set "Allow public client flows" to Yes. Rail is a desktop app and holds no client secret; without this Microsoft answers
AADSTS7000218. -
API permissions: Microsoft Graph delegated
Mail.ReadWrite,Mail.Send,User.Read,offline_access. -
Log in with
rail setup, or:rail account add m365 --address [email protected] --client-id <application-id>The client ID is the Application (client) ID on the registration's Overview page, not the Object ID. For a single-tenant app ("Accounts in this organizational directory only") also pass
--tenant <tenant-id-or-domain>(or fill in the wizard's Tenant field): Microsoft refuses the defaultcommonendpoint for single-tenant apps. Add--tenanttoo if your organisation restrictscommon.
The same steps with az. The Azure CLI can do the whole registration of an app for work or
school accounts (single or multiple organisations); personal Microsoft accounts use the portal steps
above. Use the tenant id or a domain the tenant owns for <tenant>, and AzureADMyOrg (one
organisation) or AzureADMultipleOrgs for <audience>:
az version
az login --tenant <tenant>
az account show --query tenantId --output tsv
az ad app create --display-name Rail --sign-in-audience <audience> --public-client-redirect-uris http://127.0.0.1 --is-fallback-public-client true --query appId --output tsv
az ad sp show --id 00000003-0000-0000-c000-000000000000 --query "oauth2PermissionScopes[?value=='Mail.ReadWrite' || value=='Mail.Send' || value=='User.Read' || value=='offline_access'].{value:value, id:id}" --output table
az ad app permission add --id <app-id> --api 00000003-0000-0000-c000-000000000000 --api-permissions <mail-readwrite-id>=Scope <mail-send-id>=Scope <user-read-id>=Scope <offline-access-id>=Scope
az ad app create prints the Application (client) ID; use it as <app-id> and as --client-id.
az ad sp show lists the ids of the four Microsoft Graph permissions for az ad app permission add.
An administrator can then approve Rail for the whole organisation. Approval needs the app's identity
in the tenant (an "already exists" error from the first command means it is done); if the second
command reports that the permissions or the service principal are not found, wait a minute and run
it again, because directory changes take a moment to propagate:
az ad sp create --id <app-id>
az ad app permission admin-consent --id <app-id>
Work or school accounts and admin consent
Single or multi-tenant. "Accounts in this organizational directory only" (single tenant) is
right for one organisation; log in through its tenant ID or domain. Choose "Accounts in any
organizational directory" only when several organisations share one registration; then common
works, but each organisation still has to consent.
Finding the tenant ID. In Azure Portal open Microsoft Entra ID > Overview and copy the
Tenant ID, or copy the Directory (tenant) ID from your app registration's Overview. A domain
the tenant owns (contoso.onmicrosoft.com, or a verified domain such as contoso.com) works in the
same places.
Admin consent. Many organisations stop users from approving apps themselves. An administrator approves Rail once for everyone, in any of three ways:
- From the wizard. On the Details step tick I am an administrator of this tenant. Rail opens
Microsoft's admin-consent page for your tenant, waits for the redirect to
http://127.0.0.1, and confirms, then continues with your own login. The tenant must be a concrete tenant ID or domain, notcommon; the wizard asks for it if needed. - From the portal. Open the app's API permissions and choose Grant admin consent for .
- During a normal login. An administrator who logs in is shown Microsoft's consent page with a Consent on behalf of your organization checkbox; ticking it grants consent for everyone.
If you are not an administrator and your login fails with AADSTS65001 or AADSTS90094, the
wizard shows the exact admin-consent URL. Press c to copy it, send it to an administrator, and
press r to retry once they have approved. Tenant policies (Conditional Access, app assignment,
MFA) can still intervene after consent; see Microsoft sign-in errors.
Microsoft 365 mail
What a Microsoft 365 account does today. It syncs, reads, acts on threads and writes mail over Microsoft Graph.
- Folders. Every mail folder is synced, nested ones as
Parent/Child(a/in a folder's own name shows as∕). Inbox, Sent Items, Drafts, Archive, Deleted Items and Junk Email are the account's system folders in the sidebar, under those names, and fillSentandTrashunder UNIFIED; every other folder is underMore folders, nested ones as a tree. Search folders and Conversation History, RSS Feeds, RSS Subscriptions and Sync Issues are left out. Those last four are matched by their English names, so a mailbox in another language may show them. - Threads and stars. A thread is an Outlook conversation, and a star is Outlook's flag.
- The first sync shows
⟳and a running count with no total, because Microsoft