Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
# Changelog

## [3.4.0] - 2026-08-17

### Added

- **`Session.carddavConfigured`.** Contacts go over CardDAV, which authenticates
with a username and an app password and rejects the API token everything else
uses — so `contacts` can fail on a connection where mail works perfectly.
`capabilities` could never report this: it lists what the JMAP server
advertises, and CardDAV is a separate protocol the handshake cannot see. The
only way to discover the gap was to run the query and fail, halfway through a
plan that assumed contacts were reachable. It reports whether both credentials
are present, not whether they work, and answers independently of `status`,
since they are resolved per request and stay knowable when the token is dead.
Both it and `contacts` read the same resolved value rather than loading the
config separately, so the flag cannot disagree with the operation it
describes.

- **`X-Fastmail-Username` and `X-Fastmail-App-Password` headers.** CardDAV needs
its own credentials, so a hosted deployment could never offer contacts at all
— one bearer token cannot cover two protocols. They resolve exactly like
`X-Fastmail-Token`: the request's header first, local config after, each half
independently, so a header-supplied username is never silently completed with
the host's own password.

### Changed

- **The `graphql` tool describes everyday mail itself, so most sessions never
fetch the schema.** The SDL is ~27KB, most of it the doc comments that make it
worth reading, and it used to be the only way to learn anything — so reading
mail cost a 27KB fetch first, and cost it again whenever the connection
dropped. The tool description now carries a slimmed schema: the queries, the
`EmailFilter` tree, the common `Email` fields, the connection shape and the
PREVIEW→CONFIRM flow, plus worked examples. It names what it does *not* cover,
so the remaining cases know to ask rather than guess.

- **`schema_sdl` takes a `types` list**, for those remaining cases:
`["MutationRoot"]` or `["Attachment", "MaskedEmail"]` is a few hundred bytes
rather than the lot. Definitions come back whole and documented; the types
they reference do not, so name those too. An unrecognised name is reported in
a trailing SDL comment with the names that do exist, rather than returning a
schema with a hole in it. Omitting `types` still returns everything, and a
client that sends no arguments at all is unaffected.

Both halves of that are held down by tests: the worked examples are scraped
out of the published tool descriptions and executed against the real schema,
and every field name in the inlined sketch must exist in it. A cheat sheet
that outlives a rename is worse than no cheat sheet — the first draft of this
one had already invented a `nonce` field and list-valued recipients.

## [3.3.2] - 2026-07-26

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fastmail-cli"
version = "3.3.2"
version = "3.4.0"
edition = "2024"
description = "CLI for Fastmail's JMAP API"
repository = "https://github.com/radiosilence/fastmail-cli"
Expand Down
53 changes: 46 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,14 +469,27 @@ MCP JSON-RPC, which it doesn't. That is why GraphiQL needs its own route rather
than pointing at the MCP one. Both share the schema, the client cache and the
credential resolution below, so the IDE sees exactly what a model sees.

**Token resolution is the same everywhere:** the request's `X-Fastmail-Token`
header wins, otherwise the configured token (or `FASTMAIL_API_TOKEN`) is used.
**Credential resolution is the same everywhere:** the request's own header
wins, otherwise the local config (or the matching environment variable) is used.
Running it yourself, that means your own credentials with no ceremony. In a
hosted deployment there is no local token, so the fallback is absent and every
request must carry the header, injected by a trusted upstream after it has
hosted deployment there is no local config, so the fallback is absent and every
request must carry its headers, injected by a trusted upstream after it has
authenticated the caller. Authenticated JMAP clients are cached per token, so
the JMAP session handshake runs once per distinct token rather than per call.

| Header | Falls back to | Needed for |
| -------------------------- | ----------------------- | ----------------------- |
| `X-Fastmail-Token` | `FASTMAIL_API_TOKEN` | everything over JMAP |
| `X-Fastmail-Username` | `FASTMAIL_USERNAME` | contacts (CardDAV) |
| `X-Fastmail-App-Password` | `FASTMAIL_APP_PASSWORD` | contacts (CardDAV) |

Contacts take two headers rather than riding on the token because CardDAV is a
separate protocol that rejects API tokens outright. Each resolves on its own: a
request carrying a username header but no password gets exactly that — half a
credential, which `session { carddavConfigured }` reports as `false` — rather
than quietly completing itself from the host's local config and mixing two
users together.

Do **not** expose this to the internet without such an auth layer in front —
the header is trusted unconditionally. Equally, do not run it with local
credentials present on a non-loopback address: anything that can reach the port
Expand All @@ -495,7 +508,7 @@ credentials. Queries that select any real field still authenticate as normal.
it:

```graphql
{ session { status username primaryAccountId capabilities detail } }
{ session { status username primaryAccountId capabilities carddavConfigured detail } }
```

One `GET /jmap/session` — the handshake Fastmail only completes for a request it
Expand All @@ -516,10 +529,36 @@ catch. When connected it also reports the accounts the token reaches and the
capability URNs it was granted, which is what says whether masked email and
sending are available at all.

`carddavConfigured` is the one thing there that `capabilities` cannot answer.
Capabilities are what the JMAP server advertises, and contacts go over CardDAV —
a separate protocol, with separate credentials, invisible to the handshake. So
without it the only way to find out that `contacts` is unavailable is to run it
and fail, halfway through a plan that assumed it. It reports whether a username
and app password are both present, not whether they are correct, and it answers
independently of `status`: credentials are local configuration, so a dead API
token doesn't make contact reachability unanswerable.

The MCP server exposes **2 tools** via a GraphQL interface:

- **`schema_sdl`** — returns the full GraphQL schema (SDL) so the LLM can discover all available operations
- **`graphql`** — executes any GraphQL query or mutation against the Fastmail API
- **`graphql`** — executes any GraphQL query or mutation. Its description carries a slimmed schema for everyday mail: the queries, the `EmailFilter` tree, the common `Email` fields, the connection shape and the PREVIEW→CONFIRM send flow
- **`schema_sdl`** — the full SDL, with an optional `types` list (e.g. `["MutationRoot", "Attachment"]`) returning only those definitions

The split is about round trips. The SDL is ~27KB, most of it the doc comments
that make it worth reading, and it was previously the only way to learn
anything — so reading mail cost a 27KB fetch first, and cost it again whenever
the connection dropped. The common case is now answered where the model is
already looking, and `schema_sdl` is for what the sketch explicitly says it
doesn't cover: attachment payloads, masked email, contacts, identities,
`moveEmail`, `markAsRead`, `markAsSpam`, and the remaining filter and sort
options. `types` keeps that second hop small too. Named types come back whole
but their references don't, so name those as well; an unrecognised name is
reported alongside the list of names that do exist, rather than silently
dropped.

Both the worked examples and the inlined sketch are checked by the test suite —
the examples are executed against the real schema, and every field name in the
sketch must exist in it. A cheat sheet that outlives a rename is worse than no
cheat sheet.

This replaces the previous 18 individual tools with a composable interface. The LLM fetches the schema once, then constructs exactly the queries it needs — fetching multiple resources in a single round-trip, requesting only the fields it wants, and using typed arguments for filtering and pagination.

Expand Down
47 changes: 45 additions & 2 deletions src/mcp/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,47 @@ pub type FastmailSchema = Schema<QueryRoot, MutationRoot, async_graphql::EmptySu
/// for the same Fastmail token rather than re-authenticating every call.
pub type SharedClient = std::sync::Arc<tokio::sync::Mutex<crate::jmap::JmapClient>>;

/// The credentials `contacts` needs.
///
/// CardDAV authenticates with a username and an app password and rejects API
/// tokens, so neither half comes from the JMAP credential — contacts can be
/// unreachable on an otherwise perfectly good connection.
///
/// Injected as request data rather than read inside a resolver so that
/// `Session.carddavConfigured` and the `contacts` query answer from the same
/// value. Reading the config in both places would let them disagree, and a
/// reachability flag that disagrees with the operation it describes is worse
/// than no flag.
#[derive(Clone, Debug, Default)]
pub struct CardDavCreds {
pub username: Option<String>,
pub app_password: Option<String>,
}

impl CardDavCreds {
/// Read from `~/.config/fastmail-cli/config.toml` and the environment.
///
/// The fallback for when a request carries no credential headers, exactly
/// as [`crate::mcp::local_token`] is for the token: running this yourself
/// picks up your own credentials, while a hosted deployment ships no local
/// config and every request must bring its own.
pub fn from_local_config() -> Self {
let Ok(config) = crate::config::Config::load() else {
return Self::default();
};
Self {
username: config.get_username().ok(),
app_password: config.get_app_password().ok(),
}
}

/// Both halves present, so a CardDAV request can at least be attempted.
/// Says nothing about whether the credentials are *correct*.
pub fn is_complete(&self) -> bool {
self.username.is_some() && self.app_password.is_some()
}
}

/// Maximum selection-set nesting. The graph contains cycles by design — an
/// email's thread contains emails, a mailbox's emails belong to mailboxes — so
/// unbounded depth would let one query walk forever. 15 is far past any useful
Expand Down Expand Up @@ -53,15 +94,17 @@ pub fn build_schema() -> FastmailSchema {
}

/// Build a GraphQL request carrying everything a resolver may need: the
/// authenticated JMAP client plus a fresh set of DataLoaders.
/// authenticated JMAP client, whatever CardDAV credentials exist locally, and a
/// fresh set of DataLoaders.
///
/// Loaders are per request on purpose — their cache is then a request-scoped
/// cache, so repeating a key inside one query is free while nothing is retained
/// long enough to go stale.
pub fn request(query: &str, client: SharedClient) -> async_graphql::Request {
pub fn request(query: &str, client: SharedClient, carddav: CardDavCreds) -> async_graphql::Request {
let loaders = loaders::Loaders::new(client.clone());
async_graphql::Request::new(query)
.data(client)
.data(carddav)
.data(loaders.email)
.data(loaders.mailbox)
.data(loaders.identity)
Expand Down
18 changes: 12 additions & 6 deletions src/mcp/graphql/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

use async_graphql::{Context, Object, Result};

use super::SharedClient;
use super::connection::{
EmailConnection, ListConnection, PageArgs, emails_connection, page_complexity, paginate,
};
use super::filter::{EmailFilter, EmailSort};
use super::loaders::{Emails, Identities, MaskedEmails, to_gql_error};
use super::types::*;
use super::{CardDavCreds, SharedClient};

pub struct QueryRoot;

Expand All @@ -28,7 +28,8 @@ impl QueryRoot {
/// stays distinguishable from a Fastmail outage, which is the split anyone
/// acting on this needs.
async fn session(&self, ctx: &Context<'_>) -> Result<GqlSession> {
Ok(GqlSession::probe(ctx.data::<SharedClient>()?).await)
let carddav_configured = ctx.data::<CardDavCreds>()?.is_complete();
Ok(GqlSession::probe(ctx.data::<SharedClient>()?, carddav_configured).await)
}

/// List all mailboxes (folders) with unread counts. Start here to discover available folders.
Expand Down Expand Up @@ -333,21 +334,26 @@ impl QueryRoot {
)
}

/// Search contacts by name, email, or organization. Requires FASTMAIL_APP_PASSWORD.
/// Search contacts by name, email, or organization.
///
/// Goes over CardDAV, not JMAP, so it needs a username and an app password
/// rather than the API token — check `session { carddavConfigured }` before
/// relying on it, since that is answerable without failing a query first.
#[graphql(complexity = "page_complexity(first, last, child_complexity)")]
async fn contacts(
&self,
ctx: &Context<'_>,
#[graphql(desc = "Search query — matches name, email, or organization")] query: String,
after: Option<String>,
before: Option<String>,
first: Option<i32>,
last: Option<i32>,
) -> Result<ListConnection<GqlContact>> {
let config = crate::config::Config::load()?;
let username = config.get_username().map_err(|_| {
let creds = ctx.data::<CardDavCreds>()?;
let username = creds.username.clone().ok_or_else(|| {
async_graphql::Error::new("Username not configured. Set FASTMAIL_USERNAME env var.")
})?;
let app_password = config.get_app_password().map_err(|_| {
let app_password = creds.app_password.clone().ok_or_else(|| {
async_graphql::Error::new(
"App password not configured. Set FASTMAIL_APP_PASSWORD env var (API tokens don't work for CardDAV).",
)
Expand Down
Loading