jmap: open a session per credential and derive rights
Assisted-by: GLM-5.3 via pi
change ppstusxwmzyyrqswtyvpwxvxnzxzplsn
commit 70b7cc0db3d9e0ba7d5882475611a428449ff268
author Alpha Chen <alpha@kejadlen.dev>
date
parent ulmrtuxw
diff --git a/Cargo.lock b/Cargo.lock
index c015441..d7475e9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1555,6 +1555,7 @@ dependencies = [
  "rustls-pki-types",
  "rustls-platform-verifier",
  "serde",
+ "serde_json",
  "serde_urlencoded",
  "sync_wrapper",
  "tokio",
diff --git a/Cargo.toml b/Cargo.toml
index 261e3c9..79cae76 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ jiff = "*"
 kdl = "*"
 maud = { version = "*", features = ["axum"] }
 miette = { version = "*", features = ["fancy"] }
+reqwest = { version = "*", features = ["json"] }
 rusqlite = { version = "*", features = ["bundled", "jiff"] }
 serde = { version = "*", features = ["derive"] }
 serde_json = "*"
diff --git a/docket.kdl b/docket.kdl
index 4d2d0d5..9ff3a22 100644
--- a/docket.kdl
+++ b/docket.kdl
@@ -4,3 +4,9 @@
 // first time and stands in for Tailscale. Delete docket.db to start over.
 bind "127.0.0.1:3000"
 database "docket.db"
+
+// A Fastmail API token per login (ADR 1). The name is the account's slug;
+// the token lives in the named file, never in this config. Local dev runs
+// with none and stands in with fixtures.
+// credential "household" token-file="/run/credentials/docket/household"
+// credential "eli" token-file="/run/credentials/docket/eli"
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index 3099c4c..21591ec 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -1,3 +1,4 @@
+use std::collections::BTreeSet;
 use std::fmt;
 use std::net::IpAddr;
 use std::net::Ipv4Addr;
@@ -5,6 +6,7 @@ use std::net::SocketAddr;
 
 use camino::Utf8Path;
 use camino::Utf8PathBuf;
+use docket::jmap::Credentials;
 use miette::IntoDiagnostic as _;
 use serde::Deserialize;
 use serde::Deserializer;
@@ -26,13 +28,32 @@ pub struct Config {
     /// relative to the working directory.
     #[serde(default = "default_database", deserialize_with = "non_empty_path")]
     pub database: Utf8PathBuf,
+
+    /// One node per Fastmail API token (DESIGN.md, Storage):
+    /// `credential "household" token-file="/run/credentials/docket/household"`.
+    #[serde(default, rename = "credential")]
+    pub credentials: Credentials,
 }
 
 impl Config {
     pub fn load(path: &Utf8Path) -> miette::Result<Self> {
         let source = fs_err::read_to_string(path).into_diagnostic()?;
         // Errors carry the source, so they render with their labels.
-        Ok(parse(&source)?)
+        let config = parse(&source)?;
+        config.validate()?;
+        Ok(config)
+    }
+
+    /// Credential names become account slugs, so a duplicate would
+    /// silently merge two logins into one account.
+    fn validate(&self) -> miette::Result<()> {
+        let mut seen = BTreeSet::new();
+        for credential in self.credentials.iter() {
+            if !seen.insert(&credential.name) {
+                return Err(miette::miette!("duplicate credential {}", credential.name));
+            }
+        }
+        Ok(())
     }
 }
 
@@ -83,7 +104,7 @@ impl Visitor<'_> for NonEmptyPath {
 mod tests {
     use miette::Diagnostic as _;
 
-    use super::parse;
+    use super::{Config, parse};
 
     /// The error's message, and whether it points into the source.
     fn error(source: &str) -> (String, bool) {
@@ -144,6 +165,34 @@ mod tests {
         let config = parse("bind \"0.0.0.0:3000\"\ndatabase \"d.db\"\n").unwrap();
         assert_eq!(config.bind.to_string(), "0.0.0.0:3000");
         assert_eq!(config.database, "d.db");
+        assert!(config.credentials.is_empty());
+    }
+
+    #[test]
+    fn a_lone_credential_parses() {
+        let config =
+            parse(r#"credential "household" token-file="/run/credentials/docket/household""#)
+                .unwrap();
+        let credential = config.credentials.first().unwrap();
+        assert_eq!(credential.name, "household");
+        assert_eq!(credential.token_file, "/run/credentials/docket/household");
+    }
+
+    #[test]
+    fn load_rejects_duplicate_credential_names() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("docket.kdl");
+        fs_err::write(
+            &path,
+            "credential \"household\" token-file=\"/a\"\ncredential \"household\" token-file=\"/b\"\n",
+        )
+        .unwrap();
+        let path = camino::Utf8PathBuf::try_from(path).unwrap();
+        let err = Config::load(&path).unwrap_err();
+        assert!(
+            err.to_string().contains("duplicate credential household"),
+            "{err}"
+        );
     }
 
     #[test]
diff --git a/src/bin/docket/main.rs b/src/bin/docket/main.rs
index 04226e7..763de43 100644
--- a/src/bin/docket/main.rs
+++ b/src/bin/docket/main.rs
@@ -2,6 +2,7 @@ mod config;
 
 use clap::Parser as _;
 use miette::IntoDiagnostic as _;
+use miette::WrapErr as _;
 use tokio::net::TcpListener;
 use tokio::signal;
 use tracing_subscriber::EnvFilter;
@@ -30,6 +31,16 @@ async fn main() -> miette::Result<()> {
         docket::fixtures::seed(&store)?;
         tracing::info!(database = %config.database, "seeded fixtures");
     }
+    // One session per credential, before serving: a credential that
+    // can't open one is a config or token problem to fix, not an account
+    // to quietly leave out.
+    let jmap = docket::jmap::Client::fastmail().into_diagnostic()?;
+    for credential in config.credentials.iter() {
+        jmap.sync_account(credential, &store)
+            .await
+            .into_diagnostic()
+            .wrap_err_with(|| format!("opening the JMAP session for {}", credential.name))?;
+    }
     let state = docket::routes::AppState::new(store);
     #[cfg(feature = "dev")]
     let app = docket::dev::router(state);
diff --git a/src/error.rs b/src/error.rs
index 4ad03cf..fae5fcf 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -12,6 +12,10 @@ pub enum Error {
     #[diagnostic(code(docket::db))]
     Db(#[from] rusqlite::Error),
 
+    #[error("jmap error: {0}")]
+    #[diagnostic(code(docket::jmap))]
+    Jmap(#[from] crate::jmap::JmapError),
+
     #[error("no user identity on the request")]
     #[diagnostic(code(docket::unauthenticated))]
     Unauthenticated,
@@ -32,7 +36,7 @@ pub enum Error {
 impl IntoResponse for Error {
     fn into_response(self) -> Response {
         let status = match self {
-            Error::Io(_) | Error::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
+            Error::Io(_) | Error::Db(_) | Error::Jmap(_) => StatusCode::INTERNAL_SERVER_ERROR,
             Error::Unauthenticated => StatusCode::UNAUTHORIZED,
             Error::NotFound(_) => StatusCode::NOT_FOUND,
             Error::Forbidden(_) => StatusCode::FORBIDDEN,
@@ -51,6 +55,12 @@ mod tests {
         let cases = [
             (Error::Io(std::io::Error::other("disk")), 500),
             (Error::Db(rusqlite::Error::QueryReturnedNoRows), 500),
+            (
+                Error::Jmap(crate::jmap::JmapError::NoMailAccount {
+                    credential: "household".into(),
+                }),
+                500,
+            ),
             (Error::Unauthenticated, 401),
             (Error::NotFound("thread"), 404),
             (Error::Forbidden("read-only"), 403),
diff --git a/src/jmap.rs b/src/jmap.rs
new file mode 100644
index 0000000..cbaf889
--- /dev/null
+++ b/src/jmap.rs
@@ -0,0 +1,699 @@
+//! The JMAP side of Docket: one session per credential (ADR 1), with
+//! rights derived from what the server permits rather than config. Token
+//! scope surfaces in the session as capabilities and `isReadOnly`;
+//! per-mailbox `myRights` report mailbox ACLs, so they gate per-mailbox
+//! actions like filing (task rn), never account-wide scope.
+
+use std::collections::BTreeMap;
+use std::fmt;
+use std::io::ErrorKind;
+use std::time::Duration;
+
+use camino::{Utf8Path, Utf8PathBuf};
+use serde::Deserialize;
+use serde::de;
+use serde::de::value::MapAccessDeserializer;
+use serde_json::Value;
+
+use crate::Error;
+use crate::model::Account;
+use crate::store::Store;
+
+/// RFC 8620 §9. The capability URNs Docket looks for.
+pub const CORE: &str = "urn:ietf:params:jmap:core";
+pub const MAIL: &str = "urn:ietf:params:jmap:mail";
+pub const SUBMISSION: &str = "urn:ietf:params:jmap:submission";
+
+/// Where Fastmail serves the session document (DESIGN.md: Docket is
+/// Fastmail-backed).
+pub const FASTMAIL_SESSION_URL: &str = "https://api.fastmail.com/jmap/session";
+
+#[derive(Debug, thiserror::Error)]
+pub enum JmapError {
+    #[error("reading the token file: {0}")]
+    TokenFile(#[from] std::io::Error),
+
+    #[error("JMAP request failed: {0}")]
+    Http(#[from] reqwest::Error),
+
+    #[error("malformed {what}")]
+    Malformed {
+        what: &'static str,
+        #[source]
+        source: serde_json::Error,
+    },
+
+    #[error("{method} failed: {detail}")]
+    Reply {
+        method: &'static str,
+        detail: String,
+    },
+
+    #[error("credential {credential:?} offers no mail account in its session")]
+    NoMailAccount { credential: String },
+}
+
+/// A token's session, as the server reports it (RFC 8620 §3). Only what
+/// Docket derives rights from is kept.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Session {
+    pub api_url: String,
+    /// accountId → account.
+    pub accounts: BTreeMap<String, SessionAccount>,
+    /// Capability → the accountId to use for it.
+    pub primary_accounts: BTreeMap<String, String>,
+}
+
+impl Session {
+    pub fn parse(json: &str) -> Result<Self, JmapError> {
+        serde_json::from_str(json).map_err(|source| JmapError::Malformed {
+            what: "session",
+            source,
+        })
+    }
+
+    /// The account this credential is for: the primary account for mail.
+    /// With no sharing there is exactly one per session (ADR 1).
+    pub fn mail_account(&self, credential: &str) -> Result<(&str, &SessionAccount), JmapError> {
+        let missing = || JmapError::NoMailAccount {
+            credential: credential.to_owned(),
+        };
+        let id = self.primary_accounts.get(MAIL).ok_or_else(missing)?;
+        let account = self.accounts.get(id).ok_or_else(missing)?;
+        Ok((id.as_str(), account))
+    }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SessionAccount {
+    pub name: String,
+    pub is_read_only: bool,
+    /// Capability URN → its object; only the keys matter to Docket.
+    pub account_capabilities: BTreeMap<String, Value>,
+}
+
+impl SessionAccount {
+    /// What Docket offers on this account, per what the server permits:
+    /// scope surfaces as capabilities and `isReadOnly`. `myRights` is
+    /// deliberately absent — it reports mailbox ACLs, not scope (spike
+    /// 2026-10-03).
+    pub fn rights(&self) -> Rights {
+        Rights {
+            read_only: self.is_read_only,
+            send: self.account_capabilities.contains_key(SUBMISSION),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Rights {
+    /// `isReadOnly` on the session account: no filing or replying.
+    pub read_only: bool,
+    /// The submission capability is offered: sending is on the table.
+    pub send: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Mailbox {
+    pub id: String,
+    pub name: String,
+    pub role: Option<String>,
+    pub my_rights: MailboxRights,
+}
+
+/// RFC 8621 §2.3: the ACLs on one mailbox. These reflect who may touch
+/// that mailbox, not what the token's scopes permit.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MailboxRights {
+    pub may_read_items: bool,
+    pub may_add_items: bool,
+    pub may_remove_items: bool,
+    pub may_set_keywords: bool,
+    pub may_create_child: bool,
+    pub may_rename: bool,
+    pub may_delete: bool,
+    pub may_submit: bool,
+}
+
+/// One JMAP request reply; `methodResponses` wraps each method's answer
+/// (RFC 8620 §3.4). The client invoke id comes back untouched.
+#[derive(Deserialize)]
+struct Reply {
+    #[serde(rename = "methodResponses")]
+    method_responses: Vec<(String, Value, Value)>,
+}
+
+#[derive(Deserialize)]
+struct MailboxGet {
+    list: Vec<Mailbox>,
+}
+
+#[derive(Deserialize)]
+struct ApiError {
+    #[serde(rename = "type")]
+    kind: String,
+    description: Option<String>,
+}
+
+fn parse_mailboxes(json: &str) -> Result<Vec<Mailbox>, JmapError> {
+    const WHAT: &str = "Mailbox/get reply";
+    let reply: Reply =
+        serde_json::from_str(json).map_err(|source| JmapError::Malformed { what: WHAT, source })?;
+    let detail = |text: String| JmapError::Reply {
+        method: "Mailbox/get",
+        detail: text,
+    };
+    match reply.method_responses.as_slice() {
+        [(method, args, _)] => match method.as_str() {
+            "Mailbox/get" => {
+                let got: MailboxGet = serde_json::from_value(args.clone())
+                    .map_err(|source| JmapError::Malformed { what: WHAT, source })?;
+                Ok(got.list)
+            }
+            "error" => {
+                let err: ApiError = serde_json::from_value(args.clone())
+                    .map_err(|source| JmapError::Malformed { what: WHAT, source })?;
+                Err(match err.description {
+                    Some(description) => detail(format!("{}: {description}", err.kind)),
+                    None => detail(err.kind),
+                })
+            }
+            other => Err(detail(format!("unexpected response {other:?}"))),
+        },
+        [] => Err(detail("the reply had no methodResponses".into())),
+        _ => Err(detail("the reply had more than one methodResponse".into())),
+    }
+}
+
+/// Opens sessions: one per credential, against Fastmail unless a test
+/// points the session URL at a stub.
+pub struct Client {
+    http: reqwest::Client,
+    session_url: String,
+}
+
+impl Client {
+    pub fn fastmail() -> Result<Self, JmapError> {
+        Self::new(FASTMAIL_SESSION_URL)
+    }
+
+    pub fn new(session_url: impl Into<String>) -> Result<Self, JmapError> {
+        Ok(Self {
+            http: reqwest::Client::builder()
+                .timeout(Duration::from_secs(30))
+                .build()?,
+            session_url: session_url.into(),
+        })
+    }
+
+    async fn session(&self, token: &str) -> Result<Session, JmapError> {
+        let body = self
+            .http
+            .get(&self.session_url)
+            .bearer_auth(token)
+            .send()
+            .await?
+            .error_for_status()?
+            .text()
+            .await?;
+        Session::parse(&body)
+    }
+
+    /// Every mailbox of the account, with the rights the server reports
+    /// for it.
+    async fn mailboxes(
+        &self,
+        session: &Session,
+        token: &str,
+        account_id: &str,
+    ) -> Result<Vec<Mailbox>, JmapError> {
+        let request = serde_json::json!({
+            "using": [CORE, MAIL],
+            "methodCalls": [[
+                "Mailbox/get",
+                {"accountId": account_id, "ids": null,
+                 "properties": ["id", "name", "role", "myRights"]},
+                "0",
+            ]],
+        });
+        let body = self
+            .http
+            .post(&session.api_url)
+            .bearer_auth(token)
+            .json(&request)
+            .send()
+            .await?
+            .error_for_status()?
+            .text()
+            .await?;
+        parse_mailboxes(&body)
+    }
+
+    /// Opens the credential's session and records its account in the
+    /// store, with the rights the server reports: [`Import::account`] is
+    /// the seam, and its upsert makes this the refresh path too — rights
+    /// are re-read every time a session opens.
+    ///
+    /// [`Import::account`]: crate::store::Import::account
+    pub async fn sync_account(&self, credential: &Credential, store: &Store) -> Result<(), Error> {
+        let token = read_token(&credential.token_file)?;
+        let session = self.session(&token).await?;
+        let (id, account) = session.mail_account(&credential.name)?;
+        let rights = account.rights();
+        let mailboxes = self.mailboxes(&session, &token, id).await?;
+        let mailbox_count = mailboxes.len();
+        tracing::info!(
+            credential = %credential.name,
+            account = %id,
+            read_only = rights.read_only,
+            send = rights.send,
+            mailboxes = mailbox_count,
+            "session opened",
+        );
+        store.import(|tx| {
+            tx.account(&Account {
+                slug: credential.name.clone(),
+                name: account.name.clone(),
+                // Fastmail accountIds are the login's address (spike
+                // 2026-10-03).
+                address: id.to_owned(),
+                read_only: rights.read_only,
+            })
+        })
+    }
+}
+
+/// The token stays in its file (DESIGN.md, Storage), read fresh each
+/// session so rotation needs no restart.
+fn read_token(path: &Utf8Path) -> Result<String, JmapError> {
+    let token = fs_err::read_to_string(path)?;
+    let token = token.trim();
+    if token.is_empty() {
+        return Err(std::io::Error::new(ErrorKind::InvalidData, format!("{path} is empty")).into());
+    }
+    Ok(token.to_owned())
+}
+
+/// One Fastmail API token: a name in `docket.kdl`, and the file holding
+/// the token. The name doubles as the account's slug.
+#[derive(Debug, Clone)]
+pub struct Credential {
+    pub name: String,
+    pub token_file: Utf8PathBuf,
+}
+
+/// The kdl field names of a `credential` node: the first positional
+/// argument (`#0`), a would-be second (`#1`, rejected), and the property.
+const CREDENTIAL_FIELDS: &[&str] = &["#0", "#1", "token-file"];
+
+impl<'de> Deserialize<'de> for Credential {
+    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        struct V;
+        impl<'de> de::Visitor<'de> for V {
+            type Value = Credential;
+
+            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+                f.write_str("a credential node")
+            }
+
+            fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Credential, A::Error> {
+                let mut name = None;
+                let mut token_file = None;
+                while let Some(key) = map.next_key::<String>()? {
+                    match key.as_str() {
+                        "#0" => name = map.next_value::<Option<String>>()?,
+                        "token-file" => token_file = map.next_value::<Option<String>>()?,
+                        "#1" => {
+                            return Err(de::Error::custom(
+                                "a credential takes one argument, its name",
+                            ));
+                        }
+                        other => {
+                            return Err(de::Error::custom(format!(
+                                "unexpected property `{other}`"
+                            )));
+                        }
+                    }
+                }
+                let name = name.ok_or_else(|| {
+                    de::Error::custom(
+                        "a credential is named by its argument: \
+                         credential \"household\" token-file=…",
+                    )
+                })?;
+                let token_file = token_file
+                    .ok_or_else(|| de::Error::custom("a credential needs a token-file property"))?;
+                Ok(Credential {
+                    name,
+                    token_file: Utf8PathBuf::from(token_file),
+                })
+            }
+        }
+        deserializer.deserialize_struct("Credential", CREDENTIAL_FIELDS, V)
+    }
+}
+
+/// The `credential` nodes of `docket.kdl`, flat per DESIGN.md:
+///
+/// ```kdl
+/// credential "household" token-file="/run/credentials/docket/household"
+/// ```
+///
+/// Hand-written because kdl's serde mapping feeds a lone same-named node
+/// to the field directly but a group of them as a sequence, so the type
+/// has to accept both shapes.
+#[derive(Debug, Clone, Default)]
+pub struct Credentials(pub Vec<Credential>);
+
+impl std::ops::Deref for Credentials {
+    type Target = [Credential];
+
+    fn deref(&self) -> &[Credential] {
+        &self.0
+    }
+}
+
+impl<'de> Deserialize<'de> for Credentials {
+    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        struct V;
+        impl<'de> de::Visitor<'de> for V {
+            type Value = Credentials;
+
+            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+                f.write_str("credential nodes")
+            }
+
+            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Credentials, A::Error> {
+                let mut all = Vec::new();
+                while let Some(credential) = seq.next_element::<Credential>()? {
+                    all.push(credential);
+                }
+                Ok(Credentials(all))
+            }
+
+            // A lone `credential` node: the map is the node's own
+            // arguments and properties, so it is one Credential.
+            fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Credentials, A::Error> {
+                Credential::deserialize(MapAccessDeserializer::new(map))
+                    .map(|credential| Credentials(vec![credential]))
+            }
+        }
+        deserializer.deserialize_struct("Credentials", CREDENTIAL_FIELDS, V)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use serde_json::json;
+
+    use super::*;
+
+    /// A session shaped like Fastmail's: `read_only` is a mail-only token
+    /// (the spike's findings), otherwise mail and submission.
+    fn session(read_only: bool) -> String {
+        let id = if read_only {
+            "eli@example.com"
+        } else {
+            "household@example.com"
+        };
+        let mut account = json!({
+            "name": if read_only { "Eli" } else { "Household" },
+            "isPersonal": true,
+            "isReadOnly": read_only,
+            "accountCapabilities": {
+                CORE: {},
+                MAIL: {},
+            },
+        });
+        let mut primary = json!({ MAIL: id });
+        if !read_only {
+            account["accountCapabilities"][SUBMISSION] = json!({});
+            primary[SUBMISSION] = json!(id);
+        }
+        json!({
+            "username": id,
+            "apiUrl": "https://api.fastmail.com/jmap/api/",
+            "capabilities": { CORE: {}, MAIL: {} },
+            "accounts": { id: account },
+            "primaryAccounts": primary,
+        })
+        .to_string()
+    }
+
+    #[test]
+    fn a_writable_session_offers_mail_and_submission() {
+        let session = Session::parse(&session(false)).unwrap();
+        let (id, account) = session.mail_account("household").unwrap();
+        assert_eq!(id, "household@example.com");
+        assert_eq!(account.name, "Household");
+        assert_eq!(
+            account.rights(),
+            Rights {
+                read_only: false,
+                send: true,
+            }
+        );
+    }
+
+    #[test]
+    fn a_read_only_session_offers_mail_only() {
+        let session = Session::parse(&session(true)).unwrap();
+        let (id, account) = session.mail_account("eli").unwrap();
+        assert_eq!(id, "eli@example.com");
+        assert!(!account.account_capabilities.contains_key(SUBMISSION));
+        assert_eq!(
+            account.rights(),
+            Rights {
+                read_only: true,
+                send: false,
+            }
+        );
+    }
+
+    #[test]
+    fn a_session_without_a_mail_account_is_an_error() {
+        let empty = json!({"apiUrl": "u", "accounts": {}, "primaryAccounts": {}}).to_string();
+        let err = Session::parse(&empty)
+            .unwrap()
+            .mail_account("household")
+            .unwrap_err();
+        assert!(err.to_string().contains("household"), "{err}");
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn rights(
+        read: bool,
+        add: bool,
+        remove: bool,
+        keywords: bool,
+        create_child: bool,
+        rename: bool,
+        delete: bool,
+        submit: bool,
+    ) -> Value {
+        json!({
+            "mayReadItems": read,
+            "mayAddItems": add,
+            "mayRemoveItems": remove,
+            "maySetKeywords": keywords,
+            "mayCreateChild": create_child,
+            "mayRename": rename,
+            "mayDelete": delete,
+            "maySubmit": submit,
+        })
+    }
+
+    fn mailbox(id: &str, name: &str, role: Option<&str>, my_rights: Value) -> Value {
+        json!({"id": id, "name": name, "role": role, "myRights": my_rights})
+    }
+
+    #[test]
+    fn mailboxes_unwrap_the_method_responses() {
+        let reply = json!({
+            "methodResponses": [[
+                "Mailbox/get",
+                {"accountId": "a", "state": "s", "notFound": [], "list": [
+                    mailbox("M1", "Inbox", Some("inbox"),
+                            rights(true, true, true, true, false, false, false, true)),
+                    mailbox("M2", "Receipts", None,
+                            rights(true, true, true, true, true, true, true, true)),
+                    // Server-managed, so restricted even for the owner
+                    // (spike 2026-10-03).
+                    mailbox("M3", "Scheduled", Some("scheduled"),
+                            rights(true, false, false, false, false, false, false, false)),
+                ]},
+                "0",
+            ]],
+        })
+        .to_string();
+        let mailboxes = parse_mailboxes(&reply).unwrap();
+        let names: Vec<_> = mailboxes.iter().map(|m| m.name.as_str()).collect();
+        assert_eq!(names, ["Inbox", "Receipts", "Scheduled"]);
+        assert_eq!(mailboxes.first().unwrap().role.as_deref(), Some("inbox"));
+        assert_eq!(mailboxes.get(1).unwrap().role, None);
+        let scheduled = mailboxes.get(2).unwrap();
+        assert!(scheduled.my_rights.may_read_items);
+        assert!(!scheduled.my_rights.may_add_items);
+        assert!(!scheduled.my_rights.may_submit);
+    }
+
+    #[test]
+    fn an_error_reply_fails_the_mailbox_fetch() {
+        let reply = json!({
+            "methodResponses": [[
+                "error",
+                {"type": "serverFail", "description": "boom"},
+                "0",
+            ]],
+        })
+        .to_string();
+        let err = parse_mailboxes(&reply).unwrap_err();
+        let text = err.to_string();
+        assert!(text.contains("Mailbox/get"), "{text}");
+        assert!(text.contains("serverFail: boom"), "{text}");
+
+        let undescribed = json!({
+            "methodResponses": [["error", {"type": "serverFail"}, "0"]],
+        })
+        .to_string();
+        assert!(
+            parse_mailboxes(&undescribed)
+                .unwrap_err()
+                .to_string()
+                .contains("serverFail")
+        );
+    }
+
+    #[test]
+    fn a_reply_that_is_not_the_method_asked_for_fails() {
+        let wrong = json!({"methodResponses": [["Email/get", {"list": []}, "0"]]}).to_string();
+        assert!(
+            parse_mailboxes(&wrong)
+                .unwrap_err()
+                .to_string()
+                .contains("unexpected response")
+        );
+
+        let doubled = json!({
+            "methodResponses": [
+                ["Mailbox/get", {"list": []}, "0"],
+                ["Mailbox/get", {"list": []}, "1"],
+            ],
+        })
+        .to_string();
+        assert!(
+            parse_mailboxes(&doubled)
+                .unwrap_err()
+                .to_string()
+                .contains("more than one methodResponse")
+        );
+    }
+
+    #[test]
+    fn malformed_replies_fail_to_parse() {
+        assert!(Session::parse("{").is_err());
+        assert!(parse_mailboxes("[]").is_err());
+        assert!(parse_mailboxes(r#"{"methodResponses": []}"#).is_err());
+    }
+
+    #[test]
+    fn the_fastmail_client_builds() {
+        assert!(Client::fastmail().is_ok());
+    }
+
+    #[test]
+    fn an_empty_token_file_is_an_error() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("empty.token");
+        fs_err::write(&path, " \n").unwrap();
+        let path = Utf8PathBuf::try_from(path).unwrap();
+        let err = read_token(&path).unwrap_err();
+        assert!(err.to_string().contains("is empty"), "{err}");
+    }
+
+    #[test]
+    fn a_credential_describes_what_it_expects() {
+        // The kdl paths always hand the visitors a map, so serde never
+        // has to name the expectation; a scalar makes it.
+        let err = serde_json::from_str::<Credential>("5").unwrap_err();
+        assert!(err.to_string().contains("a credential node"), "{err}");
+        let err = serde_json::from_str::<Credentials>("5").unwrap_err();
+        assert!(err.to_string().contains("credential nodes"), "{err}");
+    }
+
+    /// Mirrors how `Config` declares the field, so the kdl paths (lone
+    /// node and group) both get exercised.
+    #[derive(Debug, Deserialize)]
+    #[serde(deny_unknown_fields)]
+    struct Doc {
+        #[serde(default, rename = "credential")]
+        credentials: Credentials,
+    }
+
+    fn parse_credentials(source: &str) -> Result<Doc, kdl::de::Error> {
+        kdl::de::from_str(source)
+    }
+
+    #[test]
+    fn credentials_parse_in_document_order() {
+        let doc = parse_credentials(
+            "credential \"household\" token-file=\"/run/credentials/docket/household\"\n\
+             credential \"eli\" token-file=\"/run/credentials/docket/eli\"",
+        )
+        .unwrap();
+        let names: Vec<_> = doc.credentials.iter().map(|c| c.name.as_str()).collect();
+        assert_eq!(names, ["household", "eli"]);
+        assert_eq!(
+            doc.credentials.first().unwrap().token_file,
+            "/run/credentials/docket/household"
+        );
+    }
+
+    #[test]
+    fn a_lone_credential_parses() {
+        let doc = parse_credentials(r#"credential "household" token-file="/tok""#).unwrap();
+        let credential = doc.credentials.first().unwrap();
+        assert_eq!(credential.name, "household");
+        assert_eq!(credential.token_file, "/tok");
+    }
+
+    #[test]
+    fn no_credential_nodes_is_empty() {
+        assert!(parse_credentials("").unwrap().credentials.is_empty());
+        assert!(
+            parse_credentials("// none\n")
+                .unwrap()
+                .credentials
+                .is_empty()
+        );
+    }
+
+    #[test]
+    fn malformed_credential_nodes_fail_with_pointers() {
+        for (source, message) in [
+            (
+                r#"credential "household""#,
+                "a credential needs a token-file property",
+            ),
+            (
+                r#"credential token-file="/tok""#,
+                "a credential is named by its argument",
+            ),
+            (
+                r#"credential "household" "x" token-file="/tok""#,
+                "a credential takes one argument",
+            ),
+            (
+                r#"credential "household" file="/tok""#,
+                "unexpected property `file`",
+            ),
+        ] {
+            let err = parse_credentials(source).unwrap_err();
+            assert!(err.to_string().contains(message), "{source}: {err}");
+        }
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 13912ac..bca441f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,6 +3,7 @@ pub mod dates;
 pub mod dev;
 pub mod error;
 pub mod fixtures;
+pub mod jmap;
 pub mod lists;
 pub mod model;
 pub mod routes;
diff --git a/src/model.rs b/src/model.rs
index 833dcd7..dc8b13d 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -82,12 +82,12 @@ impl User {
     }
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Account {
     pub slug: String,
     pub name: String,
     pub address: String,
-    /// Derived from the token's rights in the real app: no filing or replying.
+    /// From the session (see `jmap`): no filing or replying when true.
     pub read_only: bool,
 }
 
diff --git a/src/store.rs b/src/store.rs
index d89d6a3..ac1b605 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -178,6 +178,18 @@ impl Store {
         Ok(folders)
     }
 
+    /// Every account, in the order sessions recorded them.
+    pub fn accounts(&self) -> Result<Vec<Account>, Error> {
+        let inner = self.lock();
+        let mut stmt = inner
+            .conn
+            .prepare_cached("SELECT slug, name, address, read_only FROM accounts ORDER BY rowid")?;
+        let accounts = stmt
+            .query_map([], account_from_row)?
+            .collect::<Result<_, _>>()?;
+        Ok(accounts)
+    }
+
     pub fn thread(&self, id: ThreadId) -> Result<Option<Thread>, Error> {
         let thread = self
             .lock()
@@ -430,9 +442,16 @@ impl Import<'_> {
         Ok(())
     }
 
+    /// An account from a session. Upserted: the first import inserts, and
+    /// every session refresh re-writes the name, address, and read-only
+    /// flag as the server now reports them.
     pub fn account(&self, account: &Account) -> Result<(), Error> {
         self.tx.execute(
-            "INSERT INTO accounts (slug, name, address, read_only) VALUES (?1, ?2, ?3, ?4)",
+            "INSERT INTO accounts (slug, name, address, read_only) VALUES (?1, ?2, ?3, ?4)
+             ON CONFLICT (slug) DO UPDATE SET
+                 name = excluded.name,
+                 address = excluded.address,
+                 read_only = excluded.read_only",
             params![
                 account.slug,
                 account.name,
@@ -562,19 +581,21 @@ fn load_thread_account(conn: &Connection, thread: ThreadId) -> Result<Option<Acc
             "SELECT a.slug, a.name, a.address, a.read_only
              FROM accounts a JOIN threads t ON t.account = a.slug WHERE t.id = ?1",
             [thread],
-            |r| {
-                Ok(Account {
-                    slug: r.get(0)?,
-                    name: r.get(1)?,
-                    address: r.get(2)?,
-                    read_only: r.get(3)?,
-                })
-            },
+            account_from_row,
         )
         .optional()?;
     Ok(account)
 }
 
+fn account_from_row(r: &Row<'_>) -> rusqlite::Result<Account> {
+    Ok(Account {
+        slug: r.get(0)?,
+        name: r.get(1)?,
+        address: r.get(2)?,
+        read_only: r.get(3)?,
+    })
+}
+
 fn folder_exists(conn: &Connection, name: &str) -> Result<bool, Error> {
     let exists = conn.query_row(
         "SELECT EXISTS (SELECT 1 FROM folders WHERE name = ?1)",
@@ -928,6 +949,37 @@ mod tests {
         assert!(store.thread_account(999).unwrap().is_none());
     }
 
+    #[test]
+    fn a_session_refresh_rewrites_an_account() {
+        let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+        let mut account = Account {
+            slug: "eli".into(),
+            name: "Eli".into(),
+            address: "eli@example.com".into(),
+            read_only: true,
+        };
+        store.import(|tx| tx.account(&account)).unwrap();
+
+        // A later session reports the account differently: same slug, new
+        // facts. The import upserts rather than colliding.
+        account.name = "Eli R.".into();
+        account.address = "eli@chislan.family".into();
+        account.read_only = false;
+        store.import(|tx| tx.account(&account)).unwrap();
+        store
+            .import(|tx| {
+                tx.thread(&Thread {
+                    id: 1,
+                    account: "eli".into(),
+                    subject: "Practice".into(),
+                })
+            })
+            .unwrap();
+
+        assert_eq!(store.accounts().unwrap(), [account]);
+        assert!(!store.thread_account(1).unwrap().unwrap().read_only);
+    }
+
     #[test]
     fn the_system_clock_reads_real_time() {
         let drift = (Clock::System.now() - jiff::Zoned::now().datetime())
diff --git a/tests/jmap.rs b/tests/jmap.rs
new file mode 100644
index 0000000..410a93e
--- /dev/null
+++ b/tests/jmap.rs
@@ -0,0 +1,163 @@
+//! The per-credential session flow against a stub Fastmail: each token
+//! opens a session, and the accounts land in the store with the rights
+//! the server reported.
+
+use std::net::SocketAddr;
+
+use axum::http::header::{AUTHORIZATION, HOST};
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{IntoResponse, Response};
+use axum::routing::{get, post};
+use axum::{Json, Router};
+use docket::jmap::{CORE, Client, Credential, MAIL, SUBMISSION};
+use docket::store::{Clock, Store};
+use serde_json::{Value, json};
+use tokio::net::TcpListener;
+
+const HOUSEHOLD_TOKEN: &str = "household-token";
+const ELI_TOKEN: &str = "eli-token";
+
+fn bearer(headers: &HeaderMap) -> Option<&str> {
+    headers.get(AUTHORIZATION)?.to_str().ok()
+}
+
+/// The session the token's scopes would earn: the household token carries
+/// mail and submission, Eli's is read-only mail only (the spike's shapes).
+fn session_document(headers: &HeaderMap, authorization: &str) -> Option<Value> {
+    let (id, name, read_only, submission) = match authorization {
+        value if value == format!("Bearer {HOUSEHOLD_TOKEN}") => {
+            ("household@example.com", "Household", false, true)
+        }
+        value if value == format!("Bearer {ELI_TOKEN}") => ("eli@example.com", "Eli", true, false),
+        _ => return None,
+    };
+    let host = headers
+        .get(HOST)
+        .and_then(|h| h.to_str().ok())
+        .unwrap_or("stub");
+    let mut capabilities = json!({ CORE: {}, MAIL: {} });
+    let mut primary = json!({ MAIL: id });
+    if submission {
+        capabilities[SUBMISSION] = json!({});
+        primary[SUBMISSION] = json!(id);
+    }
+    Some(json!({
+        "username": id,
+        "apiUrl": format!("http://{host}/api"),
+        "accounts": { id: {
+            "name": name,
+            "isPersonal": true,
+            "isReadOnly": read_only,
+            "accountCapabilities": capabilities,
+        }},
+        "primaryAccounts": primary,
+    }))
+}
+
+async fn session(headers: HeaderMap) -> Response {
+    match bearer(&headers).and_then(|token| session_document(&headers, token)) {
+        Some(document) => Json(document).into_response(),
+        None => (StatusCode::UNAUTHORIZED, "unknown token").into_response(),
+    }
+}
+
+async fn api(headers: HeaderMap, body: String) -> Response {
+    let authorized =
+        bearer(&headers).is_some_and(|token| session_document(&headers, token).is_some());
+    if !authorized {
+        return (StatusCode::UNAUTHORIZED, "unknown token").into_response();
+    }
+    // The request is Docket's own; a surprise here is a bug in this test.
+    let request: Value = serde_json::from_str(&body).expect("a json request");
+    let call = &request["methodCalls"][0];
+    assert_eq!(call[0], "Mailbox/get");
+    let account_id = call[1]["accountId"].as_str().expect("an accountId");
+    Json(json!({
+        "methodResponses": [[
+            "Mailbox/get",
+            {"accountId": account_id, "state": "s", "notFound": [], "list": [
+                {"id": "M1", "name": "Inbox", "role": "inbox",
+                 "myRights": {"mayReadItems": true, "mayAddItems": true,
+                              "mayRemoveItems": true, "maySetKeywords": true,
+                              "mayCreateChild": false, "mayRename": false,
+                              "mayDelete": false, "maySubmit": true}},
+                {"id": "M2", "name": "Receipts", "role": null,
+                 "myRights": {"mayReadItems": true, "mayAddItems": true,
+                              "mayRemoveItems": true, "maySetKeywords": true,
+                              "mayCreateChild": true, "mayRename": true,
+                              "mayDelete": true, "maySubmit": true}},
+            ]},
+            "0",
+        ]],
+    }))
+    .into_response()
+}
+
+async fn serve() -> SocketAddr {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    let app = Router::new()
+        .route("/session", get(session))
+        .route("/api", post(api));
+    tokio::spawn(async move {
+        axum::serve(listener, app).await.unwrap();
+    });
+    addr
+}
+
+/// Writes the token to its file with a trailing newline, the way a secret
+/// mounted from systemd or Docker arrives.
+fn credential(dir: &tempfile::TempDir, name: &str, token: &str) -> Credential {
+    let token_file = dir.path().join(format!("{name}.token"));
+    fs_err::write(&token_file, format!("{token}\n")).unwrap();
+    Credential {
+        name: name.to_owned(),
+        token_file: camino::Utf8PathBuf::try_from(token_file).unwrap(),
+    }
+}
+
+#[tokio::test]
+async fn each_credential_opens_a_session_and_derives_its_rights() {
+    let addr = serve().await;
+    let dir = tempfile::tempdir().unwrap();
+    let household = credential(&dir, "household", HOUSEHOLD_TOKEN);
+    let eli = credential(&dir, "eli", ELI_TOKEN);
+
+    let client = Client::new(format!("http://{addr}/session")).unwrap();
+    let store = Store::open_in_memory(Clock::System).unwrap();
+    for credential in [&household, &eli] {
+        client.sync_account(credential, &store).await.unwrap();
+    }
+
+    let accounts = store.accounts().unwrap();
+    let slugs: Vec<_> = accounts.iter().map(|a| a.slug.as_str()).collect();
+    assert_eq!(slugs, ["household", "eli"]);
+    let household_row = accounts.first().unwrap();
+    assert!(!household_row.read_only);
+    assert_eq!(household_row.name, "Household");
+    assert_eq!(household_row.address, "household@example.com");
+    let eli_row = accounts.last().unwrap();
+    assert!(eli_row.read_only);
+    assert_eq!(eli_row.address, "eli@example.com");
+
+    // Re-opening a session is the refresh path: re-read, not duplicate.
+    client.sync_account(&household, &store).await.unwrap();
+    assert_eq!(store.accounts().unwrap().len(), 2);
+}
+
+#[tokio::test]
+async fn a_rejected_token_fails_the_sync() {
+    let addr = serve().await;
+    let dir = tempfile::tempdir().unwrap();
+    let wrong = credential(&dir, "household", "not-the-token");
+
+    let client = Client::new(format!("http://{addr}/session")).unwrap();
+    let store = Store::open_in_memory(Clock::System).unwrap();
+    let err = client
+        .sync_account(&wrong, &store)
+        .await
+        .unwrap_err()
+        .to_string();
+    assert!(err.contains("401"), "{err}");
+    assert!(store.accounts().unwrap().is_empty());
+}