jmap: every state moves the mail to match
DESIGN.md now defines state by mailbox membership, so Done's archive
generalizes into a queued move for any state change on a writable
account: Do, Wait, and Watch leave the Inbox for their Docket/ label,
Done leaves both for Archive, and Inbox puts the mail back with the
label off. Undo queues the move back to the restored state instead of
cancelling, so an undone Done that already pushed returns to the Inbox.
A state whose mailbox the server lacks (no Docket/Wait yet, say) drops
with a warning and leaves the mail in place, since leaving the Inbox
without a label would read as Done to other clients. Creating missing
labels stays with task xy.
Mail Docket moves out of the Inbox no longer refreshes on poll or
reimport, which only watch the Inbox; its rows and state persist, and
adopting other mailboxes is task qwt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/migrations/0007_pending_moves.sql b/migrations/0007_pending_moves.sql
new file mode 100644
index 0000000..d11703d
--- /dev/null
+++ b/migrations/0007_pending_moves.sql
@@ -0,0 +1,15 @@
+-- Every state is a place on the server (DESIGN.md, State), so Done's
+-- archive generalizes: a state change on a writable account queues the
+-- poll loop to move the message to match — Do, Wait, and Watch out of
+-- the Inbox onto their label, Done out of both into Archive, Inbox back.
+-- The latest state per message is the only row there is.
+CREATE TABLE pending_moves (
+ message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+ state TEXT NOT NULL CHECK (state IN ('inbox', 'do', 'wait', 'watch', 'done')),
+ PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_moves (message, state)
+SELECT message, 'done' FROM pending_archives;
+
+DROP TABLE pending_archives;
diff --git a/src/jmap.rs b/src/jmap.rs
index 5e92729..e8cb354 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -434,8 +434,7 @@ struct Layout {
pub inbox: Option<String>,
/// The Sent mailbox's id.
pub sent: Option<String>,
- /// The Archive mailbox's id: where Done's exit from the Inbox
- /// lands.
+ /// The Archive mailbox's id: where Done lands.
pub archive: Option<String>,
/// Label mailbox id → the state it carries.
labels: BTreeMap<String, State>,
@@ -529,6 +528,15 @@ impl Layout {
self.folders.values().map(|f| f.name.clone()).collect()
}
+ /// The label mailbox id that carries `state`, if the server has
+ /// one. Creating missing labels is task xy.
+ fn label_of(&self, state: State) -> Option<&str> {
+ self.labels
+ .iter()
+ .find(|(_, s)| **s == state)
+ .map(|(id, _)| id.as_str())
+ }
+
/// The mailbox id a folder name files into.
fn folder_id(&self, name: &str) -> Option<&str> {
self.folders
@@ -590,41 +598,80 @@ fn filing_allowed(current: &BTreeMap<String, bool>, layout: &Layout, target: Opt
leaving && entering
}
-/// The memberships an archive leaves the message in: out of the Inbox,
-/// into Archive, with labels, folders, and other system boxes kept.
-/// None when there is no exit to make — the mail already left the
-/// Inbox, or the server has no Archive mailbox — so the caller clears
-/// the intent without touching anything.
-fn archived_mailboxes(
- current: &BTreeMap<String, bool>,
- layout: &Layout,
-) -> Option<BTreeMap<String, bool>> {
- let archive = layout.archive.as_deref()?;
- if !current.contains_key(layout.inbox.as_deref()?) {
- return None;
- }
- let mut next: BTreeMap<String, bool> = current
+/// What a queued state move comes to against fresh memberships.
+#[derive(Debug, PartialEq, Eq)]
+enum Move {
+ /// The memberships to write.
+ To(BTreeMap<String, bool>),
+ /// The mail already sits where the state says.
+ Settled,
+ /// The server lacks the mailbox the state lands in.
+ Nowhere,
+}
+
+/// The memberships that say `state` (DESIGN.md, State): out of the
+/// Inbox, Archive, and every label, then into the state's own mailbox
+/// — the Inbox, its `Docket/` label, or Archive for Done — with
+/// folders and other system boxes kept. Mail with neither Inbox nor
+/// label already reads as Done, so Done leaves it where it is rather
+/// than pulling filed mail into Archive.
+fn moved_mailboxes(current: &BTreeMap<String, bool>, layout: &Layout, state: State) -> Move {
+ let members: BTreeMap<String, bool> = current
.iter()
- .filter(|(id, member)| **member && Some(id.as_str()) != layout.inbox.as_deref())
+ .filter(|(_, member)| **member)
.map(|(id, _)| (id.clone(), true))
.collect();
- next.insert(archive.to_owned(), true);
- Some(next)
+ let in_lane = |id: &str| Some(id) == layout.inbox.as_deref() || layout.labels.contains_key(id);
+ let is_place = |id: &str| in_lane(id) || Some(id) == layout.archive.as_deref();
+ if state == State::Done && !members.keys().any(|id| in_lane(id)) {
+ return Move::Settled;
+ }
+ let target = match state {
+ State::Inbox => layout.inbox.as_deref(),
+ State::Done => layout.archive.as_deref(),
+ State::Do | State::Wait | State::Watch => layout.label_of(state),
+ };
+ let Some(target) = target else {
+ return Move::Nowhere;
+ };
+ let mut next: BTreeMap<String, bool> = members
+ .iter()
+ .filter(|(id, _)| !is_place(id))
+ .map(|(id, _)| (id.clone(), true))
+ .collect();
+ next.insert(target.to_owned(), true);
+ if next == members {
+ Move::Settled
+ } else {
+ Move::To(next)
+ }
}
-/// Whether the ACLs permit the archive: the Inbox gives the message
-/// up, and Archive takes it.
-fn archive_allowed(layout: &Layout) -> bool {
- let leaving = layout
- .inbox
- .as_deref()
- .and_then(|id| layout.rights.get(id))
- .is_none_or(|rights| rights.may_remove_items);
- let entering = layout
- .archive
- .as_deref()
- .and_then(|id| layout.rights.get(id))
- .is_some_and(|rights| rights.may_add_items);
+/// Whether the ACLs permit the move: every mailbox losing the message
+/// gives it up, and every one gaining it takes it.
+fn move_allowed(
+ current: &BTreeMap<String, bool>,
+ next: &BTreeMap<String, bool>,
+ layout: &Layout,
+) -> bool {
+ let leaving = current
+ .iter()
+ .filter(|(id, member)| **member && !next.contains_key(*id))
+ .all(|(id, _)| {
+ layout
+ .rights
+ .get(id)
+ .is_none_or(|rights| rights.may_remove_items)
+ });
+ let entering = next
+ .keys()
+ .filter(|id| current.get(*id) != Some(&true))
+ .all(|id| {
+ layout
+ .rights
+ .get(id)
+ .is_some_and(|rights| rights.may_add_items)
+ });
leaving && entering
}
@@ -673,8 +720,8 @@ pub struct PollCounts {
pub mailboxes: bool,
/// Filings pushed server-side.
pub filed: usize,
- /// Archives pushed server-side — Done's exit from the Inbox.
- pub archived: usize,
+ /// State moves pushed server-side.
+ pub moved: usize,
/// Deletions pushed server-side.
pub deleted: usize,
/// Whether the server disowned the stored states and a full
@@ -1273,7 +1320,7 @@ impl Client {
/// One poll cycle: `/changes` for mailboxes and email since the
/// states the account was left in, applying what moved, then the
- /// queued filings, archives, and deletions go out. A
+ /// queued filings, state moves, and deletions go out. A
/// `cannotCalculateChanges` reply means the server disowns those
/// states (RFC 8620 §5.2), so the cycle re-imports from scratch
/// and the caller carries the fresh states forward.
@@ -1335,8 +1382,8 @@ impl Client {
counts.filed = self
.push_files(&session, &token, id, &credential.name, &sync.layout, store)
.await?;
- counts.archived = self
- .push_archives(&session, &token, id, &credential.name, &sync.layout, store)
+ counts.moved = self
+ .push_moves(&session, &token, id, &credential.name, &sync.layout, store)
.await?;
counts.deleted = self
.push_deletes(&session, &token, id, &credential.name, store)
@@ -1492,16 +1539,15 @@ impl Client {
Ok(filed)
}
- /// Drains the queued archives — what Done means on a writable
- /// account: the mail leaves the shared Inbox for Archive, labels
- /// and folders kept. Self-limiting against fresh memberships: mail
- /// another client already filed out of the Inbox is left alone,
- /// its intent cleared silently. ACL-refused entries stay queued and
- /// nag the log until an admin grants them; server refusals and ids
- /// the server no longer carries are dropped with a warning instead
- /// of retried forever.
- #[allow(clippy::too_many_arguments)]
- async fn push_archives(
+ /// Drains the queued state moves: each message's memberships move
+ /// to the mailboxes that say its state (DESIGN.md, State), folders
+ /// kept. Self-limiting against fresh memberships: mail already
+ /// where its state says is left alone, its intent cleared silently.
+ /// ACL-refused entries stay queued and nag the log until an admin
+ /// grants them; server refusals, ids the server no longer carries,
+ /// and states whose mailbox the server lacks are dropped with a
+ /// warning instead of retried forever.
+ async fn push_moves(
&self,
session: &Session,
token: &str,
@@ -1510,7 +1556,7 @@ impl Client {
layout: &Layout,
store: &Store,
) -> Result<usize, Error> {
- let pending = store.pending_archives(slug)?;
+ let pending = store.pending_moves(slug)?;
if pending.is_empty() {
return Ok(0);
}
@@ -1530,27 +1576,31 @@ impl Client {
let mut clear: Vec<MessageId> = Vec::new();
for p in &pending {
let Some(memberships) = current.get(&p.jmap_id) else {
- tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the mail is gone server-side");
+ tracing::warn!(jmap_id = %p.jmap_id, "move dropped: the mail is gone server-side");
clear.push(p.message.clone());
continue;
};
- let Some(next) = archived_mailboxes(memberships, layout) else {
- // Already out of the Inbox, or no Archive to land in:
- // nothing to do either way. A vanished Archive mailbox
- // deserves a trace; an already-exited message doesn't.
- if layout.archive.is_none() {
- tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the server has no Archive mailbox");
+ let next = match moved_mailboxes(memberships, layout, p.state) {
+ Move::To(next) => next,
+ Move::Settled => {
+ clear.push(p.message.clone());
+ continue;
+ }
+ Move::Nowhere => {
+ // Leaving the Inbox with nowhere to land would read
+ // as Done server-side, so the mail stays put.
+ tracing::warn!(jmap_id = %p.jmap_id, state = %p.state, "move dropped: the server has no mailbox for the state");
+ clear.push(p.message.clone());
+ continue;
}
- clear.push(p.message.clone());
- continue;
};
- if !archive_allowed(layout) {
- tracing::warn!("archive held: mailbox ACLs refuse it");
+ if !move_allowed(memberships, &next, layout) {
+ tracing::warn!(state = %p.state, "move held: mailbox ACLs refuse it");
continue;
}
updates.insert(p.jmap_id.clone(), EmailPatch { mailbox_ids: next });
}
- let mut archived = 0usize;
+ let mut moved = 0usize;
let entries: Vec<(String, EmailPatch)> = updates.into_iter().collect();
for chunk in entries.chunks(PAGE) {
let update: BTreeMap<String, EmailPatch> = chunk.iter().cloned().collect();
@@ -1558,9 +1608,9 @@ impl Client {
.set_emails(&session.api_url, token, account_id, &update)
.await?;
for (id, error) in &reply.not_updated {
- tracing::warn!(jmap_id = %id, %error, "the server refused an archive");
+ tracing::warn!(jmap_id = %id, %error, "the server refused a move");
}
- archived = archived.saturating_add(reply.updated.len());
+ moved = moved.saturating_add(reply.updated.len());
// Refused ids clear too: the server's verdict stands, and
// retrying would only requeue the refusal.
clear.extend(chunk.iter().filter_map(|(id, _)| {
@@ -1570,8 +1620,8 @@ impl Client {
.map(|p| p.message.clone())
}));
}
- store.clear_pending_archives(&clear)?;
- Ok(archived)
+ store.clear_pending_moves(&clear)?;
+ Ok(moved)
}
/// Drains the account's queued deletions (task sm) with
@@ -2117,35 +2167,81 @@ mod tests {
}
#[test]
- fn archives_exit_the_inbox_and_keep_everything_else() {
+ fn state_moves_follow_the_membership_table() {
let layout = Layout::of(&mailboxes());
assert_eq!(layout.archive.as_deref(), Some("M-archive"));
+ let boxes = |ids: &[&str]| -> BTreeMap<String, bool> {
+ ids.iter().map(|id| (id.to_string(), true)).collect()
+ };
+ let to = |ids: &[&str]| Move::To(boxes(ids));
- let in_inbox = BTreeMap::from([
- ("M-in".to_string(), true),
- ("M-watch".to_string(), true),
- ("M-school".to_string(), true),
- ]);
+ // Do and Watch leave the Inbox for their label; folders stay.
+ let in_inbox = boxes(&["M-in", "M-school"]);
assert_eq!(
- archived_mailboxes(&in_inbox, &layout),
- Some(BTreeMap::from([
- ("M-watch".to_string(), true),
- ("M-school".to_string(), true),
- ("M-archive".to_string(), true),
- ]))
+ moved_mailboxes(&in_inbox, &layout, State::Do),
+ to(&["M-do", "M-school"])
+ );
+ // Switching lanes swaps the label, and leaving Archive is part
+ // of leaving Done.
+ assert_eq!(
+ moved_mailboxes(&boxes(&["M-do", "M-archive"]), &layout, State::Watch),
+ to(&["M-watch"])
+ );
+ // Inbox puts it back, label off.
+ assert_eq!(
+ moved_mailboxes(&boxes(&["M-watch", "M-school"]), &layout, State::Inbox),
+ to(&["M-in", "M-school"])
+ );
+ // Done takes it out of both and into Archive.
+ assert_eq!(
+ moved_mailboxes(
+ &boxes(&["M-in", "M-watch", "M-school"]),
+ &layout,
+ State::Done
+ ),
+ to(&["M-archive", "M-school"])
);
- // Already out of the Inbox: no exit to make, nothing touched.
- let filed_out = BTreeMap::from([("M-school".to_string(), true)]);
- assert_eq!(archived_mailboxes(&filed_out, &layout), None);
+ // Already where the state says: nothing to push. Filed mail
+ // with neither Inbox nor label already reads as Done, so Done
+ // doesn't drag it into Archive; false memberships don't count.
+ assert_eq!(
+ moved_mailboxes(&in_inbox, &layout, State::Inbox),
+ Move::Settled
+ );
+ assert_eq!(
+ moved_mailboxes(&boxes(&["M-do"]), &layout, State::Do),
+ Move::Settled
+ );
+ let filed_out =
+ BTreeMap::from([("M-school".to_string(), true), ("M-in".to_string(), false)]);
+ assert_eq!(
+ moved_mailboxes(&filed_out, &layout, State::Done),
+ Move::Settled
+ );
- // No Archive mailbox to land in: no exit either.
+ // No mailbox for the state: nowhere to land. This layout has no
+ // Docket/Wait.
+ assert_eq!(
+ moved_mailboxes(&in_inbox, &layout, State::Wait),
+ Move::Nowhere
+ );
let mut bare = Layout::of(&mailboxes());
bare.archive = None;
- assert_eq!(archived_mailboxes(&in_inbox, &bare), None);
+ bare.inbox = None;
+ assert_eq!(
+ moved_mailboxes(&boxes(&["M-do"]), &bare, State::Done),
+ Move::Nowhere
+ );
+ assert_eq!(
+ moved_mailboxes(&boxes(&["M-do"]), &bare, State::Inbox),
+ Move::Nowhere
+ );
// Full rights allow it.
- assert!(archive_allowed(&layout));
+ let next = boxes(&["M-archive", "M-school"]);
+ let current = boxes(&["M-in", "M-school"]);
+ assert!(move_allowed(¤t, &next, &layout));
let full = rights(true, true, true, true, true, true, true, true);
@@ -2163,10 +2259,10 @@ mod tests {
};
let mut hoard = full.clone();
hoard["mayRemoveItems"] = json!(false);
- assert!(!archive_allowed(&shut(hoard, full.clone())));
+ assert!(!move_allowed(¤t, &next, &shut(hoard, full.clone())));
let mut closed = full.clone();
closed["mayAddItems"] = json!(false);
- assert!(!archive_allowed(&shut(full.clone(), closed)));
+ assert!(!move_allowed(¤t, &next, &shut(full.clone(), closed)));
}
#[test]
diff --git a/src/store.rs b/src/store.rs
index a306f06..ba49524 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -25,6 +25,7 @@ const MIGRATIONS: &[&str] = &[
include_str!("../migrations/0004_pending_deletes.sql"),
include_str!("../migrations/0005_pending_archives.sql"),
include_str!("../migrations/0006_jj_style_ids.sql"),
+ include_str!("../migrations/0007_pending_moves.sql"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -345,19 +346,18 @@ impl Store {
};
let prev = values;
let mut next = prev.clone();
- // Whether this edit queued the message's exit from the shared
- // Inbox, so an undo can cancel it before the poll pushes it.
- let mut queued_archive = false;
+ // Whether this edit queued a server-side move, so an undo can
+ // queue the move back.
+ let mut queued_move = false;
let text = match change {
Change::State(state) => {
next.state = state;
- // Done means finished: on a writable account the mail
- // also leaves the shared Inbox for Archive (labels and
- // folders kept). Read-only accounts change state only.
- if state == State::Done && !read_only {
- queue_archive(&tx, id)?;
- queued_archive = true;
+ // State is where the mail sits (DESIGN.md, State): on a
+ // writable account the message moves to match.
+ // Read-only accounts change state only.
+ if !read_only {
+ queued_move = queue_move(&tx, id, state)?;
}
format!("Moved to {state}")
}
@@ -398,12 +398,15 @@ impl Store {
tx.commit()?;
inner
.undo
- .insert(user.to_owned(), (id.to_owned(), prev, queued_archive));
- let flash = if queued_archive {
- // The verb is Done; the archive is what it does.
- "Done — leaves the shared Inbox".to_owned()
- } else {
- text
+ .insert(user.to_owned(), (id.to_owned(), prev, queued_move));
+ // Every state but Inbox takes the mail out of the shared Inbox,
+ // and the flash teaches that. The verb for Done is Done.
+ let flash = match next.state {
+ State::Done if queued_move => "Done — leaves the shared Inbox".to_owned(),
+ State::Do | State::Wait | State::Watch if queued_move => {
+ format!("{text} — leaves the shared Inbox")
+ }
+ _ => text,
};
inner.flash.insert(
user.to_owned(),
@@ -417,17 +420,17 @@ impl Store {
pub fn undo(&self, user: &str) -> Result<(), Error> {
let mut inner = self.lock();
- let (id, prev, queued_archive) = inner
+ let (id, prev, queued_move) = inner
.undo
.get(user)
.cloned()
.ok_or(Error::BadRequest("nothing to undo"))?;
let tx = inner.conn.transaction()?;
- // An undone Done cancels its queued exit from the Inbox; if the
- // poll already pushed it, the mail sits in Archive and the
- // state simply reverts.
- if queued_archive {
- tx.execute("DELETE FROM pending_archives WHERE message = ?1", [&id])?;
+ // An undone state change moves the mail back. If the poll
+ // hadn't pushed the first move yet, the mail already sits where
+ // the restored state says and the push finds nothing to do.
+ if queued_move {
+ let _queued = queue_move(&tx, &id, prev.state)?;
}
// An undone filing reverts server-side too: the queued intent
// points back at the folder the undo restores.
@@ -490,7 +493,7 @@ impl Store {
/// Deletes a message (task sm): queued for the poll loop to
/// destroy server-side — which lands it in Trash — while the
/// thread goes Done here with a history event. Any queued filing
- /// or archive is superseded.
+ /// or move is superseded.
pub fn delete(&self, user: &str, id: &str) -> Result<(), Error> {
let mut inner = self.lock();
let tx = inner.conn.transaction()?;
@@ -512,7 +515,7 @@ impl Store {
[id],
)?;
tx.execute("DELETE FROM pending_files WHERE message = ?1", [id])?;
- tx.execute("DELETE FROM pending_archives WHERE message = ?1", [id])?;
+ tx.execute("DELETE FROM pending_moves WHERE message = ?1", [id])?;
write_values(&tx, id, &next)?;
insert_event(&tx, id, user, self.now(), "Deleted")?;
tx.commit()?;
@@ -562,36 +565,37 @@ impl Store {
Ok(())
}
- /// The account's queued archives — the Done-is-finished policy —
- /// for the poll loop to push.
- pub fn pending_archives(&self, account: &str) -> Result<Vec<PendingArchive>, Error> {
+ /// The account's queued state moves, for the poll loop to push. The
+ /// latest state per message is the only row there is.
+ pub fn pending_moves(&self, account: &str) -> Result<Vec<PendingMove>, Error> {
let inner = self.lock();
let mut stmt = inner.conn.prepare_cached(
- "SELECT p.message, m.jmap_id
- FROM pending_archives p JOIN messages m ON m.id = p.message
+ "SELECT p.message, m.jmap_id, p.state
+ FROM pending_moves p JOIN messages m ON m.id = p.message
WHERE m.account = ?1 ORDER BY p.message",
)?;
let pending = stmt
.query_map([account], |r| {
- Ok(PendingArchive {
+ Ok(PendingMove {
message: r.get(0)?,
jmap_id: r.get(1)?,
+ state: r.get(2)?,
})
})?
.collect::<Result<_, _>>()?;
Ok(pending)
}
- /// Drops queued archives once the server has taken them — or
- /// refused them, or never needed them.
- pub fn clear_pending_archives(&self, messages: &[MessageId]) -> Result<(), Error> {
+ /// Drops queued moves once the server has taken them — or refused
+ /// them, or never needed them.
+ pub fn clear_pending_moves(&self, messages: &[MessageId]) -> Result<(), Error> {
if messages.is_empty() {
return Ok(());
}
let mut inner = self.lock();
let tx = inner.conn.transaction()?;
for id in messages {
- tx.execute("DELETE FROM pending_archives WHERE message = ?1", [id])?;
+ tx.execute("DELETE FROM pending_moves WHERE message = ?1", [id])?;
}
tx.commit()?;
Ok(())
@@ -680,14 +684,15 @@ pub struct PendingDelete {
pub jmap_id: String,
}
-/// One queued archive, waiting for the poll loop to push it: Done's
-/// exit from the shared Inbox.
+/// One queued state move, waiting for the poll loop to push it into
+/// the mailboxes that say `state` (DESIGN.md, State).
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PendingArchive {
+pub struct PendingMove {
pub message: MessageId,
/// The JMAP Email id to move — cached on the message, read fresh at
/// drain time so a reimport's new id is the one that goes out.
pub jmap_id: String,
+ pub state: State,
}
/// Writes inside one transaction, from [`Store::import`].
@@ -1052,17 +1057,17 @@ fn queue_file(conn: &Connection, id: &str, folder: Option<&str>) -> Result<(), E
Ok(())
}
-/// Queues the message's exit from the shared Inbox (Done's meaning on
-/// a writable account). Fixture mail has no server side to leave, so
-/// rows without a cached JMAP id stay out.
-fn queue_archive(conn: &Connection, id: &str) -> Result<(), Error> {
- conn.execute(
- "INSERT INTO pending_archives (message)
- SELECT ?1 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
- ON CONFLICT (message) DO NOTHING",
- [id],
+/// Queues the move into the mailboxes that say `state`, on a writable
+/// account. Fixture mail has no server side to move, so rows without a
+/// cached JMAP id stay out. True when a move was queued.
+fn queue_move(conn: &Connection, id: &str, state: State) -> Result<bool, Error> {
+ let queued = conn.execute(
+ "INSERT INTO pending_moves (message, state)
+ SELECT ?1, ?2 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
+ ON CONFLICT (message) DO UPDATE SET state = excluded.state",
+ params![id, state],
)?;
- Ok(())
+ Ok(queued > 0)
}
fn insert_event(
@@ -1332,15 +1337,16 @@ mod tests {
.clone();
store.sign_in(SAM, "Sam").unwrap();
- // Done on a writable account queues the archive; the history
+ // Done on a writable account queues the move; the history
// names the verb, the flash teaches what it does.
store.edit(SAM, &id, Change::State(State::Done)).unwrap();
assert_eq!(values(&store, &id).state, State::Done);
assert_eq!(
- store.pending_archives("household").unwrap(),
- [PendingArchive {
+ store.pending_moves("household").unwrap(),
+ [PendingMove {
message: id.clone(),
jmap_id: "E9".into(),
+ state: State::Done,
}]
);
assert_eq!(
@@ -1358,62 +1364,93 @@ mod tests {
.collect();
assert_eq!(history, [(SAM.into(), "Moved to Done".into())]);
- // Undo reverts the state and cancels the queued exit.
+ // Undo reverts the state and queues the move back.
store.undo(SAM).unwrap();
assert_eq!(values(&store, &id).state, State::Inbox);
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert_eq!(
+ store.pending_moves("household").unwrap()[0].state,
+ State::Inbox
+ );
+
+ // The other lanes leave the Inbox too; the latest state is the
+ // one that goes out.
+ store.edit(SAM, &id, Change::State(State::Wait)).unwrap();
+ assert_eq!(
+ store.take_flash(SAM).unwrap().text,
+ "Moved to Wait — leaves the shared Inbox"
+ );
+ store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
+ assert_eq!(store.take_flash(SAM).unwrap().text, "Moved to Inbox");
+ assert_eq!(
+ store.pending_moves("household").unwrap()[0].state,
+ State::Inbox
+ );
// Another account's drain never sees them; what the poll
// pushed, it clears.
store.edit(SAM, &id, Change::State(State::Done)).unwrap();
- assert!(store.pending_archives("eli").unwrap().is_empty());
- store.clear_pending_archives(&[id.clone()]).unwrap();
- assert!(store.pending_archives("household").unwrap().is_empty());
- store.clear_pending_archives(&[]).unwrap();
+ assert!(store.pending_moves("eli").unwrap().is_empty());
+ store.clear_pending_moves(&[id.clone()]).unwrap();
+ assert!(store.pending_moves("household").unwrap().is_empty());
+ store.clear_pending_moves(&[]).unwrap();
- // Deleting a Done message supersedes its queued archive.
+ // Deleting a Done message supersedes its queued move.
store.edit(SAM, &id, Change::State(State::Done)).unwrap();
store.delete(SAM, &id).unwrap();
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert!(store.pending_moves("household").unwrap().is_empty());
assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
- // A Done that can't queue fails the whole edit, leaving the
- // state alone.
+ // A state change that can't queue fails the whole edit, leaving
+ // the state alone.
+ store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
exec(
&store,
- "CREATE TRIGGER no_pending_archive BEFORE INSERT ON pending_archives BEGIN SELECT RAISE(ABORT, 'no'); END;",
+ "CREATE TRIGGER no_pending_move BEFORE INSERT ON pending_moves BEGIN SELECT RAISE(ABORT, 'no'); END;",
);
- store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
+ store.clear_pending_moves(&[id.clone()]).unwrap();
assert!(matches!(
store.edit(SAM, &id, Change::State(State::Done)),
Err(Error::Db(_))
));
- exec(&store, "DROP TRIGGER no_pending_archive;");
assert_eq!(values(&store, &id).state, State::Inbox);
+
+ // So does an undo that can't queue the move back.
+ exec(&store, "DROP TRIGGER no_pending_move;");
+ store.edit(SAM, &id, Change::State(State::Do)).unwrap();
+ store.clear_pending_moves(&[id.clone()]).unwrap();
+ exec(
+ &store,
+ "CREATE TRIGGER no_pending_move BEFORE INSERT ON pending_moves BEGIN SELECT RAISE(ABORT, 'no'); END;",
+ );
+ assert!(matches!(store.undo(SAM), Err(Error::Db(_))));
+ exec(&store, "DROP TRIGGER no_pending_move;");
+ assert_eq!(values(&store, &id).state, State::Do);
}
#[test]
- fn done_without_a_server_side_stays_docket_local() {
- // Read-only accounts and fixture mail mark Done without queueing
- // anything.
+ fn state_without_a_server_side_stays_docket_local() {
+ // Read-only accounts and fixture mail change state without
+ // queueing anything.
let store = fixtures::store().unwrap();
let eli = fixtures::id(fixtures::ELI_PRACTICE);
store.edit(SAM, &eli, Change::State(State::Done)).unwrap();
- assert!(store.pending_archives("eli").unwrap().is_empty());
+ assert!(store.pending_moves("eli").unwrap().is_empty());
assert_eq!(store.take_flash(SAM).unwrap().text, "Moved to Done");
+ store.edit(SAM, &eli, Change::State(State::Do)).unwrap();
+ assert_eq!(store.take_flash(SAM).unwrap().text, "Moved to Do");
store
.edit(SAM, &fixtures::id(4), Change::State(State::Done))
.unwrap();
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert!(store.pending_moves("household").unwrap().is_empty());
}
#[test]
- fn a_gone_pending_archives_table_fails_the_drain() {
+ fn a_gone_pending_moves_table_fails_the_drain() {
// A fresh store, so the query compiles against the missing
// table rather than replaying a cached statement.
let store = fixtures::store().unwrap();
- exec(&store, "DROP TABLE pending_archives;");
- assert!(store.pending_archives("household").is_err());
+ exec(&store, "DROP TABLE pending_moves;");
+ assert!(store.pending_moves("household").is_err());
}
#[test]
@@ -2149,6 +2186,7 @@ mod tests {
exec(&store, "PRAGMA query_only = ON;");
let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
db_err(store.edit(SAM, &fixtures::id(4), Change::State(State::Do)));
+ db_err(store.edit(SAM, &fixtures::id(4), Change::ToggleAssignee(ALEX.into())));
db_err(store.mark_read(SAM, &fixtures::id(4)));
db_err(store.sign_in("pat@example.com", "pat").map(|_| ()));
db_err(store.add_comment(SAM, &fixtures::id(1), "hi"));
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 0452ef6..309239e 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -1383,31 +1383,22 @@ async fn done_archives_out_of_the_shared_inbox() {
store
.edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
- assert_eq!(store.pending_archives("household").unwrap().len(), 1);
+ assert_eq!(store.pending_moves("household").unwrap().len(), 1);
let counts = client
.poll_once(&household, &mut sync, &store)
.await
.unwrap();
- assert_eq!(counts.archived, 1, "{counts:?}");
+ assert_eq!(counts.moved, 1, "{counts:?}");
- // Server side: out of the Inbox, into Archive, label and folder
- // kept.
- let memberships = {
- let world = world.lock().unwrap();
- world.accounts[HOUSEHOLD]
- .emails
- .iter()
- .find(|e| e["id"] == json!("E-school"))
- .unwrap()["mailboxIds"]
- .clone()
- };
+ // Server side: out of the Inbox and the label, into Archive,
+ // folder kept.
assert_eq!(
- memberships,
- json!({"MB-watch": true, "MB-school": true, "MB-archive": true})
+ memberships_of(&world, "E-school"),
+ json!({"MB-school": true, "MB-archive": true})
);
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert!(store.pending_moves("household").unwrap().is_empty());
assert_eq!(
store
.message(&school.id)
@@ -1420,6 +1411,89 @@ async fn done_archives_out_of_the_shared_inbox() {
);
}
+/// The household stub's current memberships for one email.
+fn memberships_of(world: &World, id: &str) -> Value {
+ let world = world.lock().unwrap();
+ world.accounts[HOUSEHOLD]
+ .emails
+ .iter()
+ .find(|e| e["id"] == json!(id))
+ .unwrap()["mailboxIds"]
+ .clone()
+}
+
+#[tokio::test]
+async fn every_lane_leaves_the_shared_inbox_and_undo_brings_it_back() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ let school = school_message(&store);
+ store
+ .edit("sam@example.com", &school.id, Change::State(State::Do))
+ .unwrap();
+
+ let counts = client
+ .poll_once(&household, &mut sync, &store)
+ .await
+ .unwrap();
+
+ // Out of the Inbox, Watch's label swapped for Do's, folder kept.
+ assert_eq!(counts.moved, 1, "{counts:?}");
+ assert_eq!(
+ memberships_of(&world, "E-school"),
+ json!({"MB-do": true, "MB-school": true})
+ );
+ assert!(store.pending_moves("household").unwrap().is_empty());
+
+ // Undo restores Watch, so the mail goes back to the Watch label —
+ // still out of the Inbox, since every lane but Inbox is.
+ store.undo("sam@example.com").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-watch": true, "MB-school": true})
+ );
+
+ // Inbox puts it back, label off.
+ store
+ .edit("sam@example.com", &school.id, Change::State(State::Inbox))
+ .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-in": true, "MB-school": true})
+ );
+}
+
+#[tokio::test]
+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.
+ 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, 0, "{counts:?}");
+ assert!(store.pending_moves("household").unwrap().is_empty());
+ assert_eq!(
+ memberships_of(&world, "E-school"),
+ json!({"MB-in": true, "MB-watch": true, "MB-school": true})
+ );
+}
+
#[tokio::test]
async fn mail_already_out_of_the_inbox_exits_nowhere() {
let (world, client, household, mut sync, store, _dir) = household_ready().await;
@@ -1442,8 +1516,8 @@ async fn mail_already_out_of_the_inbox_exits_nowhere() {
.unwrap();
// No exit to make: the intent clears and nothing moves.
- assert_eq!(counts.archived, 0, "{counts:?}");
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert_eq!(counts.moved, 0, "{counts:?}");
+ assert!(store.pending_moves("household").unwrap().is_empty());
let memberships = {
let world = world.lock().unwrap();
world.accounts[HOUSEHOLD]
@@ -1474,8 +1548,8 @@ async fn acl_denied_archives_wait_for_the_rights() {
.poll_once(&household, &mut sync, &store)
.await
.unwrap();
- assert_eq!(counts.archived, 0, "{counts:?}");
- assert_eq!(store.pending_archives("household").unwrap().len(), 1);
+ assert_eq!(counts.moved, 0, "{counts:?}");
+ assert_eq!(store.pending_moves("household").unwrap().len(), 1);
// The rights come back and the waiting exit goes through.
world
@@ -1488,8 +1562,8 @@ async fn acl_denied_archives_wait_for_the_rights() {
.poll_once(&household, &mut sync, &store)
.await
.unwrap();
- assert_eq!(counts.archived, 1, "{counts:?}");
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert_eq!(counts.moved, 1, "{counts:?}");
+ assert!(store.pending_moves("household").unwrap().is_empty());
}
#[tokio::test]
@@ -1506,8 +1580,8 @@ async fn a_refused_archive_clears_rather_than_retries() {
.await
.unwrap();
- assert_eq!(counts.archived, 0, "{counts:?}");
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert_eq!(counts.moved, 0, "{counts:?}");
+ assert!(store.pending_moves("household").unwrap().is_empty());
}
#[tokio::test]
@@ -1524,8 +1598,8 @@ async fn an_archive_for_mail_thats_gone_drops() {
.await
.unwrap();
- assert_eq!(counts.archived, 0, "{counts:?}");
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert_eq!(counts.moved, 0, "{counts:?}");
+ assert!(store.pending_moves("household").unwrap().is_empty());
}
#[tokio::test]
@@ -1546,8 +1620,8 @@ async fn an_archive_with_nowhere_to_land_drops() {
.await
.unwrap();
- assert_eq!(counts.archived, 0, "{counts:?}");
- assert!(store.pending_archives("household").unwrap().is_empty());
+ assert_eq!(counts.moved, 0, "{counts:?}");
+ assert!(store.pending_moves("household").unwrap().is_empty());
let memberships = {
let world = world.lock().unwrap();
world.accounts[HOUSEHOLD]