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>
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;