jmap: adopt state changes made in other clients
Adoption keys off a change in the mailboxes since the last sight
(messages.server_state), not a mismatch with Docket, so mail Docket
couldn't move, like Wait with no Docket/Wait label, doesn't snap back.
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/migrations/0008_adoption.sql b/migrations/0008_adoption.sql
new file mode 100644
index 0000000..d2a3c99
--- /dev/null
+++ b/migrations/0008_adoption.sql
@@ -0,0 +1,29 @@
+-- Adopting state changes made in other clients (task qwt). Mailbox
+-- moves made elsewhere are user actions, so the poll reads them as a
+-- new state, but only when the mailboxes themselves changed: a message
+-- the server couldn't move (a lane without its label) must not snap
+-- back on its next unrelated update. server_state is the state the
+-- mailboxes said when the poll last saw the message, the baseline that
+-- change is measured from. NULL until the next sight records one.
+ALTER TABLE messages ADD COLUMN server_state TEXT
+ CHECK (server_state IN ('inbox', 'do', 'wait', 'watch', 'done'));
+
+-- An adopted change has no Docket user behind it: a NULL user is
+-- "via another client". history is rebuilt (not ALTERed) because
+-- SQLite can't drop NOT NULL; rowid carries the order across.
+CREATE TABLE history_new (
+ id TEXT PRIMARY KEY,
+ message TEXT NOT NULL REFERENCES messages (id),
+ -- NULL when the change came from another client.
+ user TEXT REFERENCES users (login),
+ at TEXT NOT NULL,
+ event TEXT NOT NULL
+) STRICT;
+
+INSERT INTO history_new (rowid, id, message, user, at, event)
+SELECT rowid, id, message, user, at, event FROM history ORDER BY rowid;
+
+DROP TABLE history;
+ALTER TABLE history_new RENAME TO history;
+
+CREATE INDEX history_message ON history (message);
diff --git a/src/jmap.rs b/src/jmap.rs
index e8cb354..6c5dd61 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -436,6 +436,8 @@ struct Layout {
pub sent: Option<String>,
/// The Archive mailbox's id: where Done lands.
pub archive: Option<String>,
+ /// The Trash mailbox's id: Done whatever labels it keeps.
+ pub trash: Option<String>,
/// Label mailbox id → the state it carries.
labels: BTreeMap<String, State>,
/// Mailbox ids in the Docket namespace: the labels and the parent.
@@ -462,8 +464,9 @@ impl Layout {
Some("inbox") => layout.inbox = Some(mailbox.id.clone()),
Some("sent") => layout.sent = Some(mailbox.id.clone()),
Some("archive") => layout.archive = Some(mailbox.id.clone()),
- // The other system boxes (Drafts, Junk, Trash)
- // are neither folders nor labels.
+ Some("trash") => layout.trash = Some(mailbox.id.clone()),
+ // The other system boxes (Drafts, Junk, ...) are
+ // neither folders nor labels.
Some(_) => {}
None => {
let in_docket = mailbox.name == "Docket"
@@ -490,13 +493,33 @@ impl Layout {
layout
}
- /// The state the message carries: its `Docket/` label, else Inbox.
+ /// The state the message's mailboxes say (DESIGN.md, State): Done
+ /// in Trash whatever labels it keeps, else its `Docket/` label, else
+ /// Inbox when it sits there, else Done.
pub fn state_of(&self, email: &Email) -> State {
+ let member = |id: &str| email.mailbox_ids.get(id) == Some(&true);
+ if self.trash.as_deref().is_some_and(member) {
+ return State::Done;
+ }
+ let label = email
+ .mailbox_ids
+ .iter()
+ .filter(|(_, member)| **member)
+ .find_map(|(id, _)| self.labels.get(id).copied());
+ match label {
+ Some(state) => state,
+ None if self.is_inbox(email) => State::Inbox,
+ None => State::Done,
+ }
+ }
+
+ /// True when the message carries a `Docket/` label: state the poll
+ /// follows even if Docket never saw it in the Inbox.
+ pub fn is_labeled(&self, email: &Email) -> bool {
email
.mailbox_ids
- .keys()
- .find_map(|id| self.labels.get(id).copied())
- .unwrap_or(State::Inbox)
+ .iter()
+ .any(|(id, member)| *member && self.labels.contains_key(id))
}
/// The folder the message is filed in: its role-less mailbox outside
@@ -1209,8 +1232,8 @@ impl Client {
/// received messages land in Inbox — or the state their `Docket/`
/// label already carries — unassigned; sent ones carry a login when
/// the From address names one of us, else the shared identity.
- /// Filed and archived mail stays out until adoption (task qwt)
- /// watches for it.
+ /// Filed and archived mail stays out; once mail is tracked, the
+ /// poll follows it out of the Inbox (task qwt).
#[allow(clippy::too_many_arguments)]
async fn import_mail(
&self,
@@ -1429,11 +1452,18 @@ impl Client {
let (emails, _) = self.emails(&session.api_url, &token, id, &ids).await?;
let users = store.users()?;
// Received first: a new Inbox message founds the thread
- // its same-batch sent reply then lands in.
+ // its same-batch sent reply then lands in. Labeled mail
+ // is Docket's too, and mail already tracked stays
+ // followed out of the Inbox: leaving is a state change
+ // (task qwt).
for email in &emails {
- if sync.layout.is_inbox(email)
- && let Some(mail) = incoming(email, &sync.layout, &users)
- {
+ let Some(mail) = incoming(email, &sync.layout, &users) else {
+ continue;
+ };
+ let followed = !sync.layout.is_sent(email)
+ && (sync.layout.is_labeled(email)
+ || store.has_message(&credential.name, &mail.message_id)?);
+ if sync.layout.is_inbox(email) || followed {
store.import(|tx| tx.incoming(&credential.name, &mail))?;
counts.imported = counts.imported.saturating_add(1);
}
@@ -2377,6 +2407,12 @@ mod tests {
// Not a member: filed out of Inbox, or a sent message.
let gone = email(&[("M-receipts", true)]);
assert!(!layout.is_inbox(&gone));
+ // Neither Inbox nor label: Done. A lapsed membership is none.
+ assert_eq!(layout.state_of(&gone), State::Done);
+ assert_eq!(
+ layout.state_of(&email(&[("M-in", true), ("M-do", false)])),
+ State::Inbox
+ );
assert!(!layout.is_inbox(&email(&[("M-in", false)])));
let mine = email(&[("M-sent", true)]);
diff --git a/src/model.rs b/src/model.rs
index 817340b..56e6147 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -169,12 +169,13 @@ pub struct Comment {
pub text: String,
}
-/// A change someone made to a message's values, as its toast put it.
+/// A change someone made to a message's values, as its toast put it,
+/// or one adopted from another client.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
pub message: MessageId,
- /// A login.
- pub user: String,
+ /// A login, or None for a change made in another client.
+ pub user: Option<String>,
pub at: DateTime,
pub text: String,
}
diff --git a/src/store.rs b/src/store.rs
index 9e32d7d..4b9f758 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -28,6 +28,7 @@ const MIGRATIONS: &[&str] = &[
include_str!("../migrations/0005_pending_archives.sql"),
include_str!("../migrations/0006_jj_style_ids.sql"),
include_str!("../migrations/0007_pending_moves.sql"),
+ include_str!("../migrations/0008_adoption.sql"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -174,6 +175,7 @@ impl Store {
let mut inner = self.lock();
let import = Import {
tx: inner.conn.transaction()?,
+ now: self.now(),
};
f(&import)?;
import.tx.commit()?;
@@ -232,6 +234,18 @@ impl Store {
/// True when the account's thread is already imported: the poll
/// path admits a sent email only into a thread it knows.
+ /// Whether the account already tracks the message, by Message-ID:
+ /// mail that leaves the Inbox stays the poll's business once known.
+ pub fn has_message(&self, account: &str, message_id: &str) -> Result<bool, Error> {
+ let inner = self.lock();
+ let known = inner.conn.query_row(
+ "SELECT EXISTS (SELECT 1 FROM messages WHERE account = ?1 AND message_id = ?2)",
+ [account, message_id],
+ |r| r.get(0),
+ )?;
+ Ok(known)
+ }
+
pub fn has_thread(&self, account: &str, jmap_thread_id: &str) -> Result<bool, Error> {
let found = self.lock().conn.query_row(
"SELECT EXISTS(SELECT 1 FROM threads
@@ -437,7 +451,7 @@ impl Store {
};
write_values(&tx, id, &next)?;
- insert_event(&tx, id, user, self.now(), &text)?;
+ insert_event(&tx, id, Some(user), self.now(), &text)?;
tx.commit()?;
inner
.undo
@@ -475,7 +489,7 @@ impl Store {
queue_file(&tx, &id, prev.folder.as_deref())?;
}
write_values(&tx, &id, &prev)?;
- insert_event(&tx, &id, user, self.now(), "Undone")?;
+ insert_event(&tx, &id, Some(user), self.now(), "Undone")?;
tx.commit()?;
inner.undo.remove(user);
inner.flash.insert(
@@ -551,7 +565,7 @@ impl Store {
tx.execute("DELETE FROM pending_files 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")?;
+ insert_event(&tx, id, Some(user), self.now(), "Deleted")?;
tx.commit()?;
inner.flash.insert(
user.to_owned(),
@@ -732,6 +746,8 @@ pub struct PendingMove {
/// Writes inside one transaction, from [`Store::import`].
pub struct Import<'a> {
tx: Transaction<'a>,
+ /// The clock adopted changes are stamped with.
+ now: DateTime,
}
impl Import<'_> {
@@ -846,7 +862,15 @@ impl Import<'_> {
/// values (state, assignees, reads) are written only on first
/// import, while the server-owned cache (folder, timestamps, body)
/// refreshes.
+ ///
+ /// The one exception is state changed in another client (task qwt):
+ /// when the mailboxes say a new state since the last sight, Docket
+ /// adopts it with a history event rather than reverting it. A queued
+ /// move or deletion is Docket's newer word and holds; read-only
+ /// accounts keep state in the database alone, so nothing there
+ /// adopts.
pub fn incoming(&self, account: &str, mail: &Incoming) -> Result<(), Error> {
+ let prior = self.prior(account, &mail.message_id)?;
self.tx.execute(
"INSERT INTO threads (id, account, subject, jmap_thread_id)
VALUES (?1, ?2, ?3, ?4)
@@ -887,10 +911,11 @@ impl Import<'_> {
let bcc = json(&mail.bcc)?;
let _upserted = self.tx.execute(
"INSERT INTO messages (id, account, message_id, jmap_id, thread, at, cc, bcc, body,
- kind, from_name, from_addr, state, folder, sent_by, sent_to)
- SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16
+ kind, from_name, from_addr, state, folder, sent_by, sent_to, server_state)
+ SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?13
FROM threads t WHERE t.account = ?2 AND t.jmap_thread_id = ?5
ON CONFLICT (account, message_id) DO UPDATE SET
+ server_state = excluded.server_state,
jmap_id = excluded.jmap_id,
thread = excluded.thread,
at = excluded.at,
@@ -921,8 +946,72 @@ impl Import<'_> {
sent_to,
],
)?;
+ if let (Some(prior), Some(seen)) = (prior, state)
+ && prior.adopts(*seen)
+ {
+ self.tx.execute(
+ "UPDATE messages SET state = ?2 WHERE id = ?1",
+ params![prior.id, seen],
+ )?;
+ insert_event(
+ &self.tx,
+ &prior.id,
+ None,
+ self.now,
+ &format!("Moved to {seen} via another client"),
+ )?;
+ }
Ok(())
}
+
+ /// What a received message held before this sight, for adoption.
+ fn prior(&self, account: &str, message_id: &str) -> Result<Option<Prior>, Error> {
+ let prior = self
+ .tx
+ .query_row(
+ "SELECT m.id, m.state, m.server_state,
+ EXISTS (SELECT 1 FROM pending_moves p WHERE p.message = m.id)
+ OR EXISTS (SELECT 1 FROM pending_deletes d WHERE d.message = m.id),
+ a.read_only
+ FROM messages m JOIN accounts a ON a.slug = m.account
+ WHERE m.account = ?1 AND m.message_id = ?2 AND m.kind = 'received'",
+ [account, message_id],
+ |r| {
+ Ok(Prior {
+ id: r.get(0)?,
+ state: r.get(1)?,
+ server_state: r.get(2)?,
+ queued: r.get(3)?,
+ read_only: r.get(4)?,
+ })
+ },
+ )
+ .optional()?;
+ Ok(prior)
+ }
+}
+
+/// A received message as it stood before a sight of it.
+struct Prior {
+ id: MessageId,
+ state: State,
+ /// What the mailboxes said last time; None before any sight.
+ server_state: Option<State>,
+ /// A move or deletion Docket has yet to push.
+ queued: bool,
+ read_only: bool,
+}
+
+impl Prior {
+ /// Whether the mailboxes' `seen` state is another client's change
+ /// to adopt: they moved since the last sight, to somewhere Docket
+ /// doesn't already say, with nothing of Docket's own in flight.
+ fn adopts(&self, seen: State) -> bool {
+ self.server_state.is_some_and(|last| last != seen)
+ && self.state != seen
+ && !self.queued
+ && !self.read_only
+ }
}
impl ToSql for State {
@@ -1102,10 +1191,11 @@ fn queue_move(conn: &Connection, id: &str, state: State) -> Result<bool, Error>
Ok(queued > 0)
}
+/// `user` is None for a change adopted from another client.
fn insert_event(
conn: &Connection,
message: &str,
- user: &str,
+ user: Option<&str>,
at: DateTime,
text: &str,
) -> Result<(), Error> {
@@ -1216,8 +1306,8 @@ mod tests {
assert_eq!(
history,
[
- (SAM.into(), store.now(), "Moved to Do".into()),
- (SAM.into(), store.now(), "Undone".into()),
+ (Some(SAM.into()), store.now(), "Moved to Do".into()),
+ (Some(SAM.into()), store.now(), "Undone".into()),
]
);
}
@@ -1393,7 +1483,7 @@ mod tests {
.into_iter()
.map(|e| (e.user, e.text))
.collect();
- assert_eq!(history, [(SAM.into(), "Moved to Done".into())]);
+ assert_eq!(history, [(Some(SAM.into()), "Moved to Done".into())]);
// Undo reverts the state and queues the move back.
store.undo(SAM).unwrap();
@@ -1535,8 +1625,8 @@ mod tests {
assert_eq!(
history,
[
- (SAM.into(), "Filed to House".into()),
- (SAM.into(), "Deleted".into())
+ (Some(SAM.into()), "Filed to House".into()),
+ (Some(SAM.into()), "Deleted".into())
]
);
@@ -1837,7 +1927,12 @@ mod tests {
assert!(!store.has_thread("household", "T8").unwrap());
// Threads are scoped to the account.
assert!(!store.has_thread("eli", "T9").unwrap());
+ assert!(store.has_message("household", "m9@chislan.family").unwrap());
+ assert!(!store.has_message("eli", "m9@chislan.family").unwrap());
+ exec(&store, "ALTER TABLE messages RENAME TO gone_messages;");
+ assert!(store.has_message("household", "m9@chislan.family").is_err());
+ exec(&store, "ALTER TABLE gone_messages RENAME TO messages;");
exec(&store, "ALTER TABLE threads RENAME TO gone_threads;");
assert!(store.has_thread("household", "T9").is_err());
}
@@ -1888,6 +1983,45 @@ mod tests {
assert_eq!(after.values().unwrap().folder, None);
}
+ #[test]
+ fn incoming_adopts_a_state_changed_elsewhere() {
+ let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+ let sight =
+ |state| store.import(|tx| tx.incoming("household", &incoming(state, None, "x")));
+ store
+ .import(|tx| {
+ tx.account(&Account {
+ slug: "household".into(),
+ name: "Household".into(),
+ address: "household@example.com".into(),
+ read_only: false,
+ })
+ })
+ .unwrap();
+ sight(State::Inbox).unwrap();
+ let id = store.messages(Filter::Search("x")).unwrap()[0].id.clone();
+
+ sight(State::Watch).unwrap();
+ assert_eq!(values(&store, &id).state, State::Watch);
+ assert_eq!(
+ store.history(&id).unwrap(),
+ [Event {
+ message: id.clone(),
+ user: None,
+ at: fixtures::now(),
+ text: "Moved to Watch via another client".into(),
+ }]
+ );
+
+ // A failed event rolls the adoption back with it.
+ exec(
+ &store,
+ "CREATE TRIGGER no_history BEFORE INSERT ON history BEGIN SELECT RAISE(ABORT, 'no'); END;",
+ );
+ assert!(matches!(sight(State::Do), Err(Error::Db(_))));
+ assert_eq!(values(&store, &id).state, State::Watch);
+ }
+
#[test]
fn incoming_reports_a_table_that_refuses_writes() {
let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 309239e..4cc6cb8 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -113,6 +113,7 @@ fn mailboxes() -> Vec<Value> {
("MB-in", "Inbox", Some("inbox"), &rights),
("MB-sent", "Sent", Some("sent"), &rights),
("MB-archive", "Archive", Some("archive"), &rights),
+ ("MB-trash", "Trash", Some("trash"), &rights),
("MB-drafts", "Drafts", Some("drafts"), &rights),
("MB-sched", "Scheduled", Some("scheduled"), &restricted),
("MB-do", "Docket/Do", None, &rights),
@@ -929,11 +930,43 @@ async fn new_inbox_mail_arrives_within_one_poll() {
assert_eq!(arrived.values().unwrap().folder, None);
}
+/// Rewrites one household email's memberships, as another client would,
+/// and polls once.
+async fn moved_elsewhere(
+ world: &World,
+ client: &Client,
+ household: &Credential,
+ sync: &mut docket::jmap::Sync,
+ store: &Store,
+ id: &str,
+ mailboxes: Value,
+) {
+ world
+ .lock()
+ .unwrap()
+ .update_email(HOUSEHOLD, id, |mail| mail["mailboxIds"] = mailboxes);
+ client.poll_once(household, sync, store).await.unwrap();
+}
+
+/// The message's state and its history, as (user, text) pairs.
+fn state_and_history(store: &Store, message_id: &str) -> (State, Vec<(Option<String>, String)>) {
+ let message = the(store, message_id).unwrap();
+ let history = store
+ .history(&message.id)
+ .unwrap()
+ .into_iter()
+ .map(|e| (e.user, e.text))
+ .collect();
+ (message.values().unwrap().state, history)
+}
+
+fn elsewhere(text: &str) -> (Option<String>, String) {
+ (None, text.to_owned())
+}
+
#[tokio::test]
-async fn edits_refresh_the_row_while_state_adoption_waits() {
+async fn a_folder_picked_up_elsewhere_refreshes_the_row() {
let (world, client, household, mut sync, store, _dir) = household_ready().await;
-
- // A folder picked up elsewhere refreshes the cached row.
world
.lock()
.unwrap()
@@ -950,22 +983,311 @@ async fn edits_refresh_the_row_while_state_adoption_waits() {
roofer1.values().unwrap().folder.as_deref(),
Some("Receipts")
);
+ // Still in the Inbox: no state changed, so none is adopted.
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family"),
+ (State::Inbox, vec![])
+ );
+}
+
+#[tokio::test]
+async fn a_label_added_elsewhere_sets_its_state() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ // Mail.app's drag: out of the Inbox, onto the label.
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer1",
+ json!({"MB-watch": true}),
+ )
+ .await;
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family"),
+ (
+ State::Watch,
+ vec![elsewhere("Moved to Watch via another client")]
+ )
+ );
+
+ // Option-drag keeps it in the Inbox too; the label still wins.
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer1",
+ json!({"MB-in": true, "MB-do": true}),
+ )
+ .await;
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family").0,
+ State::Do
+ );
+}
- // A `Docket/` label added elsewhere is adoption's to apply, not the
- // poll's: Docket-owned state keeps what it holds.
+#[tokio::test]
+async fn a_label_removed_elsewhere_with_no_replacement_sets_done() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ // School sits in the Inbox, Watch, and School; Docket moves it to
+ // Watch's label alone first, as a Watch message normally sits.
+ let school = school_message(&store);
+ store
+ .edit("sam@example.com", &school.id, Change::State(State::Watch))
+ .unwrap();
+ client
+ .poll_once(&household, &mut sync, &store)
+ .await
+ .unwrap();
+ assert_eq!(
+ memberships_of(&world, "E-school"),
+ json!({"MB-watch": true, "MB-school": true})
+ );
+
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-school",
+ json!({"MB-school": true}),
+ )
+ .await;
+ let message = store.message(&school.id).unwrap().unwrap();
+ assert_eq!(message.values().unwrap().state, State::Done);
+ let history: Vec<_> = store
+ .history(&school.id)
+ .unwrap()
+ .into_iter()
+ .map(|e| (e.user, e.text))
+ .collect();
+ assert_eq!(
+ history,
+ [
+ (Some("sam@example.com".into()), "Moved to Watch".into()),
+ elsewhere("Moved to Done via another client"),
+ ]
+ );
+}
+
+#[tokio::test]
+async fn trash_is_done_whatever_labels_it_keeps() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer2",
+ json!({"MB-trash": true, "MB-do": true}),
+ )
+ .await;
+ assert_eq!(
+ state_and_history(&store, "E-roofer2@chislan.family"),
+ (
+ State::Done,
+ vec![elsewhere("Moved to Done via another client")]
+ )
+ );
+}
+
+#[tokio::test]
+async fn inbox_mail_filed_out_elsewhere_is_done() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer1",
+ json!({"MB-receipts": true}),
+ )
+ .await;
+ let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+ assert_eq!(
+ roofer1.values().unwrap().folder.as_deref(),
+ Some("Receipts")
+ );
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family"),
+ (
+ State::Done,
+ vec![elsewhere("Moved to Done via another client")]
+ )
+ );
+
+ // Dragged back into the Inbox, it's Inbox again.
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer1",
+ json!({"MB-in": true, "MB-receipts": true}),
+ )
+ .await;
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family").0,
+ State::Inbox
+ );
+}
+
+#[tokio::test]
+async fn labeled_mail_docket_never_saw_arrives_in_its_state() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ // Filed straight to a label by a rule or another client, never in
+ // the Inbox.
+ world.lock().unwrap().add_email(
+ HOUSEHOLD,
+ email_json(
+ "E-labeled",
+ "T-labeled",
+ ("City Water", "billing@citywater.gov"),
+ "household",
+ &["MB-do", "MB-receipts"],
+ "Overdue notice",
+ Some("Pay by Friday."),
+ "Pay by Friday",
+ ),
+ );
+ // A senderless oddity under a label has nothing to show.
+ let mut noise = email_json(
+ "E-labeled-noise",
+ "T-labeled-noise",
+ ("", ""),
+ "household",
+ &["MB-do"],
+ "(no subject)",
+ None,
+ "",
+ );
+ noise["from"] = Value::Null;
+ world.lock().unwrap().add_email(HOUSEHOLD, noise);
+ // And unlabeled filed mail stays out.
+ world.lock().unwrap().add_email(
+ HOUSEHOLD,
+ email_json(
+ "E-filed",
+ "T-filed",
+ ("City Water", "billing@citywater.gov"),
+ "household",
+ &["MB-receipts"],
+ "Receipt",
+ Some("Thanks."),
+ "Thanks",
+ ),
+ );
+
+ let counts = client
+ .poll_once(&household, &mut sync, &store)
+ .await
+ .unwrap();
+
+ assert_eq!(counts.imported, 1, "{counts:?}");
+ assert_eq!(
+ state_and_history(&store, "E-labeled@chislan.family"),
+ (State::Do, vec![])
+ );
+ assert!(the(&store, "E-filed@chislan.family").is_none());
+}
+
+#[tokio::test]
+async fn a_queued_docket_change_outranks_one_made_elsewhere() {
+ let (world, client, household, mut sync, store, _dir) = household_ready().await;
+ // The Archive refuses mail, so Done stays queued.
world
.lock()
.unwrap()
- .update_email(HOUSEHOLD, "E-roofer1", |mail| {
- mail["mailboxIds"]["MB-do"] = json!(true);
+ .update_mailbox(HOUSEHOLD, "MB-archive", |m| {
+ m["myRights"]["mayAddItems"] = json!(false);
});
- let counts = client
+ let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+ store
+ .edit("sam@example.com", &roofer1.id, Change::State(State::Done))
+ .unwrap();
+
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer1",
+ json!({"MB-do": true}),
+ )
+ .await;
+
+ assert_eq!(store.pending_moves("household").unwrap().len(), 1);
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family"),
+ (
+ State::Done,
+ vec![(Some("sam@example.com".into()), "Moved to Done".into())]
+ )
+ );
+}
+
+#[tokio::test]
+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.
+ let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+ store
+ .edit("sam@example.com", &roofer1.id, Change::State(State::Wait))
+ .unwrap();
+ client
.poll_once(&household, &mut sync, &store)
.await
.unwrap();
+
+ // An unrelated change (a folder) brings it back through the poll;
+ // the mailboxes still say what they said, so nothing is adopted.
+ moved_elsewhere(
+ &world,
+ &client,
+ &household,
+ &mut sync,
+ &store,
+ "E-roofer1",
+ json!({"MB-in": true, "MB-receipts": true}),
+ )
+ .await;
+ assert_eq!(
+ state_and_history(&store, "E-roofer1@chislan.family").0,
+ State::Wait
+ );
+}
+
+#[tokio::test]
+async fn read_only_accounts_adopt_nothing() {
+ 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();
+ let (client, mut sync) = synced(&addr, &store, &eli).await;
+
+ world
+ .lock()
+ .unwrap()
+ .update_email(ELI, "E-practice", |mail| {
+ mail["mailboxIds"] = json!({"MB-do": true});
+ });
+ let counts = client.poll_once(&eli, &mut sync, &store).await.unwrap();
+
assert_eq!(counts.imported, 1, "{counts:?}");
- let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
- assert_eq!(roofer1.values().unwrap().state, State::Inbox);
+ assert_eq!(
+ state_and_history(&store, "E-practice@chislan.family"),
+ (State::Inbox, vec![])
+ );
}
#[tokio::test]