jmap: open a session per credential and derive rights
Assisted-by: GLM-5.3 via pi
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());
+}