jmap: create missing Docket labels
A writable account without Docket/Do, Docket/Wait, or Docket/Watch had
nowhere to put mail moved to that state, so the move was dropped:
Docket said Watch while Fastmail still showed the Inbox. Opening a
session, and any later mailbox change, now creates the missing labels
with one Mailbox/set under a Docket parent, creating the parent in the
same call when there isn't one. Read-only accounts are left alone.

Nested labels never worked either. Mailbox/get didn't ask for parentId,
and Layout::of looked up the mailbox's own id among the Docket parents
rather than its parent's, so a Wait inside Docket read as a folder. The
test meant to cover this added a parent but no children.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change qzwqzxknnkwvovqzowspstrpynslzvkr
commit 2dfb0a8229509ebfce4f95eb6b6b853fe6530e0d
author Alpha Chen <alpha@kejadlen.dev>
date
parent pnnuzxpq
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index d181ef8..4146041 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -75,7 +75,7 @@ State lives in Docket's database. On writable accounts it is also where the mess
 | `Docket/Do`, `Docket/Wait`, or `Docket/Watch` | Do, Wait, or Watch |
 | Neither | Done |
 
-The `Docket/` mailboxes are **labels**. JMAP mailbox membership is per message, so a message can be in `Receipts` and labeled `Docket/Watch` at once, and state is visible (and editable) in Mail.app and Fastmail web. Changing state in Docket moves the message to match: Do, Wait, and Watch take it out of the Inbox and add the label, Done takes it out of both and into Archive, and Inbox puts it back. A message in the Inbox that also has a label takes the label's state. On read-only accounts, state is database-only.
+The `Docket/` mailboxes are **labels**. Docket creates any that are missing on writable accounts, nested under a `Docket` parent. JMAP mailbox membership is per message, so a message can be in `Receipts` and labeled `Docket/Watch` at once, and state is visible (and editable) in Mail.app and Fastmail web. Changing state in Docket moves the message to match: Do, Wait, and Watch take it out of the Inbox and add the label, Done takes it out of both and into Archive, and Inbox puts it back. A message in the Inbox that also has a label takes the label's state. On read-only accounts, state is database-only.
 
 Per-person read tracking lives in the database — `$seen` is shared across clients and can't say who read something. Opening a message in Docket marks it read for that user. Read tracking drives unread marks only; it never changes state or assignees.
 
diff --git a/src/jmap.rs b/src/jmap.rs
index a24d2ce..fd4910d 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -134,6 +134,9 @@ pub struct Rights {
 pub struct Mailbox {
     pub id: String,
     pub name: String,
+    /// The enclosing mailbox, or None at the top level: a name is only
+    /// its own leaf (RFC 8621 §2).
+    pub parent_id: Option<String>,
     pub role: Option<String>,
     pub my_rights: MailboxRights,
 }
@@ -455,6 +458,8 @@ struct Layout {
     labels: BTreeMap<String, State>,
     /// Mailbox ids in the Docket namespace: the labels and the parent.
     docket: BTreeSet<String>,
+    /// The `Docket` parent mailbox's id, when the labels nest.
+    parent: Option<String>,
     /// Folder mailbox id → its name and ACLs.
     folders: BTreeMap<String, Folder>,
     /// Every mailbox's ACLs, for the moves that cross system boxes.
@@ -484,9 +489,15 @@ impl Layout {
                 None => {
                     let in_docket = mailbox.name == "Docket"
                         || mailbox.name.starts_with("Docket/")
-                        || parents.contains(mailbox.id.as_str());
+                        || mailbox
+                            .parent_id
+                            .as_deref()
+                            .is_some_and(|p| parents.contains(p));
                     if in_docket {
                         layout.docket.insert(mailbox.id.clone());
+                        if parents.contains(mailbox.id.as_str()) {
+                            layout.parent = Some(mailbox.id.clone());
+                        }
                         let leaf = mailbox.name.rsplit('/').next().unwrap_or_default();
                         if let Some(state) = State::from_slug(&leaf.to_lowercase()) {
                             layout.labels.insert(mailbox.id.clone(), state);
@@ -564,8 +575,16 @@ impl Layout {
         self.folders.values().map(|f| f.name.clone()).collect()
     }
 
+    /// The lane states whose label the server lacks.
+    fn missing_labels(&self) -> Vec<State> {
+        [State::Do, State::Wait, State::Watch]
+            .into_iter()
+            .filter(|state| self.label_of(*state).is_none())
+            .collect()
+    }
+
     /// The label mailbox id that carries `state`, if the server has
-    /// one. Creating missing labels is task xy.
+    /// one.
     fn label_of(&self, state: State) -> Option<&str> {
         self.labels
             .iter()
@@ -719,6 +738,14 @@ fn parse_set_emails(args: Value) -> Result<SetEmailsReply, JmapError> {
     })
 }
 
+/// Parses a `Mailbox/set` create reply's verdict on each creation.
+fn parse_create_mailboxes(args: Value) -> Result<CreateMailboxesReply, JmapError> {
+    serde_json::from_value(args).map_err(|source| JmapError::Malformed {
+        what: "Mailbox/set",
+        source,
+    })
+}
+
 /// Parses an `Email/set` destroy reply's verdict on each id.
 fn parse_destroy_emails(args: Value) -> Result<DestroyEmailsReply, JmapError> {
     serde_json::from_value(args).map_err(|source| JmapError::Malformed {
@@ -799,6 +826,7 @@ struct GetMailboxes<'a> {
 enum MailboxProperty {
     Id,
     Name,
+    ParentId,
     Role,
     MyRights,
 }
@@ -806,10 +834,38 @@ enum MailboxProperty {
 const MAILBOX_PROPERTIES: &[MailboxProperty] = &[
     MailboxProperty::Id,
     MailboxProperty::Name,
+    MailboxProperty::ParentId,
     MailboxProperty::Role,
     MailboxProperty::MyRights,
 ];
 
+/// `Mailbox/set` create arguments (RFC 8621 §2.5), keyed by creation
+/// id; a `#id` parent refers to a mailbox created in the same call
+/// (RFC 8620 §5.3).
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CreateMailboxes<'a> {
+    account_id: &'a str,
+    create: &'a BTreeMap<String, NewMailbox>,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct NewMailbox {
+    name: String,
+    parent_id: Option<String>,
+}
+
+/// The outcome half of a `Mailbox/set` create reply.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CreateMailboxesReply {
+    #[serde(default)]
+    created: BTreeMap<String, Value>,
+    #[serde(default)]
+    not_created: BTreeMap<String, SetError>,
+}
+
 /// `Email/query` arguments (RFC 8621 §4.4).
 #[derive(Debug, Serialize)]
 #[serde(rename_all = "camelCase")]
@@ -1063,6 +1119,80 @@ impl Client {
         parse_stateful_list("Mailbox/get", args)
     }
 
+    /// The account's mailboxes with Docket's labels in place (task
+    /// xy): a writable account missing any of `Docket/Do`,
+    /// `Docket/Wait`, or `Docket/Watch` gets them under a `Docket`
+    /// parent, created too if need be, so state moves have somewhere
+    /// to land. Read-only accounts keep state database-only.
+    async fn labeled_mailboxes(
+        &self,
+        session: &Session,
+        token: &str,
+        account_id: &str,
+        read_only: bool,
+    ) -> Result<(Vec<Mailbox>, String), JmapError> {
+        let (mailboxes, state) = self.mailboxes(session, token, account_id).await?;
+        let layout = Layout::of(&mailboxes);
+        let missing = layout.missing_labels();
+        if read_only || missing.is_empty() {
+            return Ok((mailboxes, state));
+        }
+        let mut create = BTreeMap::new();
+        let parent = match layout.parent {
+            Some(id) => id,
+            None => {
+                create.insert(
+                    "docket".to_owned(),
+                    NewMailbox {
+                        name: "Docket".to_owned(),
+                        parent_id: None,
+                    },
+                );
+                "#docket".to_owned()
+            }
+        };
+        for state in missing {
+            create.insert(
+                state.slug().to_owned(),
+                NewMailbox {
+                    name: state.name().to_owned(),
+                    parent_id: Some(parent.clone()),
+                },
+            );
+        }
+        let args = self
+            .call(
+                &session.api_url,
+                token,
+                "Mailbox/set",
+                &CreateMailboxes {
+                    account_id,
+                    create: &create,
+                },
+            )
+            .await?;
+        let reply = parse_create_mailboxes(args)?;
+        for (id, error) in &reply.not_created {
+            let name = match create.get(id) {
+                Some(NewMailbox {
+                    parent_id: Some(_),
+                    name,
+                }) => format!("Docket/{name}"),
+                _ => "Docket".to_owned(),
+            };
+            tracing::error!(
+                account = %account_id,
+                "Fastmail refused to create the {name} mailbox ({error}). State moves \
+                 that need it stay in Docket only; create {name} in Fastmail instead."
+            );
+        }
+        if reply.created.is_empty() {
+            return Ok((mailboxes, state));
+        }
+        tracing::info!(account = %account_id, created = ?reply.created.keys(), "created Docket labels");
+        self.mailboxes(session, token, account_id).await
+    }
+
     /// Posts one method call and returns the paired response's arguments.
     async fn call<A>(
         &self,
@@ -1211,7 +1341,9 @@ impl Client {
         let session = self.session(&token).await?;
         let (id, account) = session.mail_account(&credential.name)?;
         let rights = account.rights();
-        let (mailboxes, mailbox_state) = self.mailboxes(&session, &token, id).await?;
+        let (mailboxes, mailbox_state) = self
+            .labeled_mailboxes(&session, &token, id, rights.read_only)
+            .await?;
         let layout = Layout::of(&mailboxes);
         let users = store.users()?;
         let folders = layout.folder_names();
@@ -1417,7 +1549,9 @@ impl Client {
         // An unchanged reply still advances the state (RFC 8620 §5.2).
         sync.mailbox_state = mailboxes.new_state.clone();
         if mailboxes.changed() {
-            let (mailboxes, mailbox_state) = self.mailboxes(&session, &token, id).await?;
+            let (mailboxes, mailbox_state) = self
+                .labeled_mailboxes(&session, &token, id, account.rights().read_only)
+                .await?;
             sync.layout = Layout::of(&mailboxes);
             sync.mailbox_state = mailbox_state;
             let rights = account.rights();
@@ -1888,7 +2022,36 @@ mod tests {
         assert_eq!(
             mailboxes,
             json!({"accountId": "a", "ids": null,
-                   "properties": ["id", "name", "role", "myRights"]})
+                   "properties": ["id", "name", "parentId", "role", "myRights"]})
+        );
+
+        let create = BTreeMap::from([
+            (
+                "docket".to_owned(),
+                NewMailbox {
+                    name: "Docket".into(),
+                    parent_id: None,
+                },
+            ),
+            (
+                "wait".to_owned(),
+                NewMailbox {
+                    name: "Wait".into(),
+                    parent_id: Some("#docket".into()),
+                },
+            ),
+        ]);
+        let labels = serde_json::to_value(CreateMailboxes {
+            account_id: "a",
+            create: &create,
+        })
+        .unwrap();
+        assert_eq!(
+            labels,
+            json!({"accountId": "a", "create": {
+                "docket": {"name": "Docket", "parentId": null},
+                "wait": {"name": "Wait", "parentId": "#docket"},
+            }})
         );
 
         let query = serde_json::to_value(QueryEmails {
@@ -2357,6 +2520,12 @@ mod tests {
         assert!(err.to_string().contains("malformed Email/set"), "{err}");
     }
 
+    #[test]
+    fn a_mailbox_create_reply_that_wont_parse_fails() {
+        let err = parse_create_mailboxes(json!({"created": 3})).unwrap_err();
+        assert!(err.to_string().contains("malformed Mailbox/set"), "{err}");
+    }
+
     #[test]
     fn set_emails_serializes_and_replies_parse() {
         let update = BTreeMap::from([(
@@ -2418,20 +2587,24 @@ mod tests {
         );
 
         // A label nested under a `Docket` parent classifies the same as
-        // a prefixed name.
-        let nested = serde_json::from_value::<Mailbox>(mailbox(
-            "M-parent",
-            "Docket",
-            None,
-            rights(true, true, true, true, true, true, true, true),
-        ))
-        .unwrap();
+        // a prefixed name; its leaf is the whole name.
+        let full = rights(true, true, true, true, true, true, true, true);
+        let parent = mailbox("M-parent", "Docket", None, full.clone());
+        let mut child = mailbox("M-wait", "Wait", None, full);
+        child["parentId"] = json!("M-parent");
         let mut all = mailboxes();
-        all.insert(0, nested);
+        for m in [parent, child] {
+            all.push(serde_json::from_value::<Mailbox>(m).unwrap());
+        }
+        let layout = Layout::of(&all);
         assert_eq!(
-            Layout::of(&all).folder_names(),
+            layout.folder_names(),
             ["Receipts", "School"].map(String::from)
         );
+        assert_eq!(layout.label_of(State::Wait), Some("M-wait"));
+        assert_eq!(layout.parent.as_deref(), Some("M-parent"));
+        assert!(layout.missing_labels().is_empty());
+        assert_eq!(Layout::of(&mailboxes()).missing_labels(), [State::Wait]);
     }
 
     /// An Email with the given mailbox memberships.
diff --git a/tests/jmap.rs b/tests/jmap.rs
index ecb0f20..e5e5dcc 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -311,6 +311,8 @@ struct Stub {
     accounts: BTreeMap<&'static str, AccountMail>,
     /// Email ids `Email/set` refuses, to exercise the notUpdated path.
     refuse: BTreeSet<String>,
+    /// Mailbox names `Mailbox/set` refuses to create.
+    refuse_mailboxes: BTreeSet<String>,
 }
 
 #[derive(Default)]
@@ -488,7 +490,52 @@ async fn api(world: axum::extract::State<World>, headers: HeaderMap, body: Strin
     let method = call[0].as_str().expect("a method name").to_owned();
     let args = &call[1];
 
-    // The mutating method, so it locks for itself and answers early.
+    // The mutating methods, so they lock for themselves and answer
+    // early.
+    if method == "Mailbox/set" {
+        let mut stub = world.lock().unwrap();
+        let mut created = serde_json::Map::new();
+        let mut not_created = serde_json::Map::new();
+        // Creation ids resolve in order, so a parent comes first; its
+        // `#id` is what children name as their parent (RFC 8620 §5.3).
+        let create = args["create"].as_object().expect("a create map");
+        let mut order: Vec<_> = create.iter().collect();
+        order.sort_by_key(|(_, m)| !m["parentId"].is_null());
+        for (cid, new) in order {
+            let name = new["name"].as_str().expect("a name");
+            let parent = match new["parentId"].as_str() {
+                Some(r) if r.starts_with('#') => match created.get(&r[1..]) {
+                    Some(made) => made["id"].clone(),
+                    None => {
+                        not_created.insert(cid.clone(), json!({"type": "invalidProperties"}));
+                        continue;
+                    }
+                },
+                other => json!(other),
+            };
+            if stub.refuse_mailboxes.contains(name) {
+                not_created.insert(
+                    cid.clone(),
+                    json!({"type": "forbidden", "description": "refused"}),
+                );
+                continue;
+            }
+            let id = format!("MB-new-{cid}");
+            stub.accounts
+                .get_mut(account)
+                .unwrap()
+                .mailboxes
+                .push(json!({
+                    "id": id, "name": name, "parentId": parent, "role": null,
+                    "myRights": full_rights(),
+                }));
+            created.insert(cid.clone(), json!({"id": id}));
+        }
+        stub.snapshot(account);
+        let reply = json!({"accountId": account, "oldState": "s", "newState": "s",
+                           "created": created, "notCreated": not_created});
+        return Json(json!({"methodResponses": [["Mailbox/set", reply, "0"]]})).into_response();
+    }
     if method == "Email/set" {
         // One method for both: a destroy carries `destroy`, a patch
         // `update` (RFC 8620 §5.3).
@@ -1279,6 +1326,7 @@ async fn mail_docket_couldnt_move_keeps_its_state() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     // No Docket/Wait on the server: the mail stays in the Inbox while
     // Docket says Wait.
+    lose_the_wait_label(&world);
     let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
     store
         .edit("sam@example.com", &roofer1.id, Change::State(State::Wait))
@@ -1837,8 +1885,9 @@ async fn every_lane_leaves_the_shared_inbox_and_undo_brings_it_back() {
 async fn a_lane_without_its_label_keeps_the_mail_in_the_inbox() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;
     let school = school_message(&store);
-    // The stub has no Docket/Wait; leaving the Inbox without it would
-    // read as Done to every other client.
+    // Without Docket/Wait, leaving the Inbox would read as Done to
+    // every other client.
+    lose_the_wait_label(&world);
     store
         .edit("sam@example.com", &school.id, Change::State(State::Wait))
         .unwrap();
@@ -1856,6 +1905,104 @@ async fn a_lane_without_its_label_keeps_the_mail_in_the_inbox() {
     );
 }
 
+/// The household stub's mailbox named `name`.
+fn mailbox_named(world: &World, name: &str) -> Option<Value> {
+    let world = world.lock().unwrap();
+    world.accounts[HOUSEHOLD]
+        .mailboxes
+        .iter()
+        .find(|m| m["name"] == json!(name))
+        .cloned()
+}
+
+/// Deletes the Docket/Wait label sync created and refuses to make it
+/// again, the way a server whose ACLs shut Docket out would.
+fn lose_the_wait_label(world: &World) {
+    let mut world = world.lock().unwrap();
+    world.refuse_mailboxes.insert("Wait".into());
+    world.remove_mailbox(HOUSEHOLD, "MB-new-wait");
+}
+
+#[tokio::test]
+async fn sync_creates_missing_labels_under_a_docket_parent() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+
+    // The stub carries Docket/Do and Docket/Watch, so only Wait is new,
+    // nested under a new Docket parent.
+    let parent = mailbox_named(&world, "Docket").expect("a Docket parent");
+    assert_eq!(parent["parentId"], Value::Null);
+    let wait = mailbox_named(&world, "Wait").expect("a Wait label");
+    assert_eq!(wait["parentId"], parent["id"]);
+    assert!(mailbox_named(&world, "Do").is_none());
+    // Labels aren't folders.
+    assert_eq!(store.folders().unwrap(), ["Receipts", "School"]);
+
+    // A Wait move now has somewhere to land.
+    let school = school_message(&store);
+    store
+        .edit("sam@example.com", &school.id, Change::State(State::Wait))
+        .unwrap();
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+    assert_eq!(counts.moved, 1, "{counts:?}");
+    assert_eq!(
+        memberships_of(&world, "E-school"),
+        json!({"MB-new-wait": true, "MB-school": true})
+    );
+}
+
+#[tokio::test]
+async fn a_label_deleted_server_side_comes_back() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    world
+        .lock()
+        .unwrap()
+        .remove_mailbox(HOUSEHOLD, "MB-new-wait");
+
+    let counts = client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    assert!(counts.mailboxes, "{counts:?}");
+    let wait = mailbox_named(&world, "Wait").expect("a recreated Wait label");
+    assert_eq!(wait["parentId"], json!("MB-new-docket"));
+}
+
+#[tokio::test]
+async fn read_only_accounts_get_no_labels() {
+    init_tracing();
+    let (addr, world) = serve().await;
+    let dir = tempfile::tempdir().unwrap();
+    let eli = credential(&dir, "eli", ELI_TOKEN);
+    let store = Store::open_in_memory(Clock::System).unwrap();
+    synced(&addr, &store, &eli).await;
+
+    let world = world.lock().unwrap();
+    assert_eq!(world.accounts[ELI].mailboxes, mailboxes());
+}
+
+#[tokio::test]
+async fn a_refused_docket_parent_still_syncs() {
+    init_tracing();
+    let (addr, world) = serve().await;
+    world
+        .lock()
+        .unwrap()
+        .refuse_mailboxes
+        .insert("Docket".into());
+    let dir = tempfile::tempdir().unwrap();
+    let household = credential(&dir, "household", HOUSEHOLD_TOKEN);
+    let store = Store::open_in_memory(Clock::System).unwrap();
+    synced(&addr, &store, &household).await;
+
+    // The children hang off the refused parent, so none were made.
+    assert!(mailbox_named(&world, "Docket").is_none());
+    assert!(mailbox_named(&world, "Wait").is_none());
+}
+
 #[tokio::test]
 async fn mail_already_out_of_the_inbox_exits_nowhere() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;