Replace auto-increment ids with jj-style ones
Ids are 64 random bits in reverse hex — nibble 0 renders as z, jj's
change-id alphabet. The migration generates them in SQL, hex(randomblob)
with each digit substituted, since SQLite has no translate().
Assisted-by: GLM-5.3 via pi
diff --git a/migrations/0006_jj_style_ids.sql b/migrations/0006_jj_style_ids.sql
new file mode 100644
index 0000000..cb5181e
--- /dev/null
+++ b/migrations/0006_jj_style_ids.sql
@@ -0,0 +1,185 @@
+-- jj-style ids replace the INTEGER PRIMARY KEYs (task uu): random
+-- 16-character reverse-hex strings — jj's change-id alphabet, where
+-- nibble 0 renders as z. SQLite can't change a primary key in place,
+-- so each table is rebuilt, as 0002 did for messages; an `old` column
+-- carries the numeric id across the rebuilds and comes off at the end.
+--
+-- The id expression is hex(randomblob(8)) with every digit substituted:
+-- 0123456789abcdef -> zyxwvutsrqponmlk. Nested replace() calls are safe
+-- here because no substituted character is ever substituted again.
+
+CREATE TABLE threads_new (
+ id TEXT PRIMARY KEY,
+ account TEXT NOT NULL REFERENCES accounts (slug),
+ subject TEXT NOT NULL,
+ jmap_thread_id TEXT,
+ old INTEGER NOT NULL
+) STRICT;
+
+INSERT INTO threads_new (id, account, subject, jmap_thread_id, old)
+SELECT replace(replace(replace(replace(replace(replace(replace(replace(
+ replace(replace(replace(replace(replace(replace(replace(replace(
+ lower(hex(randomblob(8))),
+ '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+ '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+ account, subject, jmap_thread_id, id
+FROM threads;
+
+DROP TABLE threads;
+ALTER TABLE threads_new RENAME TO threads;
+
+CREATE TABLE messages_new (
+ id TEXT PRIMARY KEY,
+ account TEXT NOT NULL REFERENCES accounts (slug),
+ message_id TEXT NOT NULL,
+ jmap_id TEXT,
+ thread TEXT NOT NULL REFERENCES threads (id),
+ at TEXT NOT NULL,
+ cc TEXT NOT NULL DEFAULT '[]',
+ bcc TEXT NOT NULL DEFAULT '[]',
+ body TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('received', 'sent')),
+ from_name TEXT,
+ from_addr TEXT,
+ state TEXT CHECK (state IN ('inbox', 'do', 'wait', 'watch', 'done')),
+ folder TEXT REFERENCES folders (name),
+ sent_by TEXT REFERENCES users (login),
+ sent_to TEXT,
+ old INTEGER NOT NULL,
+ UNIQUE (account, message_id),
+ CHECK (
+ (kind = 'received'
+ AND from_name IS NOT NULL AND from_addr IS NOT NULL AND state IS NOT NULL
+ AND sent_by IS NULL AND sent_to IS NULL)
+ OR (kind = 'sent'
+ AND sent_to IS NOT NULL
+ AND from_name IS NULL AND from_addr IS NULL AND state IS NULL AND folder IS NULL)
+ )
+) STRICT;
+
+INSERT INTO messages_new (id, account, message_id, jmap_id, thread, at, cc, bcc, body,
+ kind, from_name, from_addr, state, folder, sent_by, sent_to, old)
+SELECT replace(replace(replace(replace(replace(replace(replace(replace(
+ replace(replace(replace(replace(replace(replace(replace(replace(
+ lower(hex(randomblob(8))),
+ '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+ '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+ m.account, m.message_id, m.jmap_id, t.id, m.at, m.cc, m.bcc, m.body,
+ m.kind, m.from_name, m.from_addr, m.state, m.folder, m.sent_by, m.sent_to, m.id
+FROM messages m JOIN threads t ON t.old = m.thread;
+
+DROP TABLE messages;
+ALTER TABLE messages_new RENAME TO messages;
+
+CREATE TABLE assignees_new (
+ message TEXT NOT NULL REFERENCES messages (id),
+ user TEXT NOT NULL REFERENCES users (login),
+ PRIMARY KEY (message, user)
+) STRICT;
+
+INSERT INTO assignees_new (message, user)
+SELECT m.id, a.user FROM assignees a JOIN messages m ON m.old = a.message;
+
+DROP TABLE assignees;
+ALTER TABLE assignees_new RENAME TO assignees;
+
+CREATE TABLE reads_new (
+ user TEXT NOT NULL REFERENCES users (login),
+ message TEXT NOT NULL REFERENCES messages (id),
+ PRIMARY KEY (user, message)
+) STRICT;
+
+INSERT INTO reads_new (user, message)
+SELECT r.user, m.id FROM reads r JOIN messages m ON m.old = r.message;
+
+DROP TABLE reads;
+ALTER TABLE reads_new RENAME TO reads;
+
+CREATE TABLE comments_new (
+ id TEXT PRIMARY KEY,
+ thread TEXT NOT NULL REFERENCES threads (id),
+ author TEXT NOT NULL REFERENCES users (login),
+ at TEXT NOT NULL,
+ text TEXT NOT NULL
+) STRICT;
+
+INSERT INTO comments_new (id, thread, author, at, text)
+SELECT replace(replace(replace(replace(replace(replace(replace(replace(
+ replace(replace(replace(replace(replace(replace(replace(replace(
+ lower(hex(randomblob(8))),
+ '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+ '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+ t.id, c.author, c.at, c.text
+FROM comments c JOIN threads t ON t.old = c.thread;
+
+DROP TABLE comments;
+ALTER TABLE comments_new RENAME TO comments;
+
+CREATE TABLE history_new (
+ id TEXT PRIMARY KEY,
+ message TEXT NOT NULL REFERENCES messages (id),
+ user TEXT NOT NULL REFERENCES users (login),
+ at TEXT NOT NULL,
+ event TEXT NOT NULL
+) STRICT;
+
+-- Random ids carry no order, so the insertion order history needs lives
+-- in the rowid the new table already has.
+INSERT INTO history_new (rowid, id, message, user, at, event)
+SELECT h.rowid,
+ replace(replace(replace(replace(replace(replace(replace(replace(
+ replace(replace(replace(replace(replace(replace(replace(replace(
+ lower(hex(randomblob(8))),
+ '0','z'),'1','y'),'2','x'),'3','w'),'4','v'),'5','u'),'6','t'),'7','s'),
+ '8','r'),'9','q'),'a','p'),'b','o'),'c','n'),'d','m'),'e','l'),'f','k'),
+ m.id, h.user, h.at, h.event
+FROM history h JOIN messages m ON m.old = h.message
+ORDER BY h.rowid;
+
+DROP TABLE history;
+ALTER TABLE history_new RENAME TO history;
+
+-- The queued-intent tables point at messages too, so they take the
+-- same rebuild.
+CREATE TABLE pending_files_new (
+ message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+ folder TEXT REFERENCES folders (name),
+ PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_files_new (message, folder)
+SELECT m.id, p.folder FROM pending_files p JOIN messages m ON m.old = p.message;
+
+DROP TABLE pending_files;
+ALTER TABLE pending_files_new RENAME TO pending_files;
+
+CREATE TABLE pending_deletes_new (
+ message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+ PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_deletes_new (message)
+SELECT m.id FROM pending_deletes p JOIN messages m ON m.old = p.message;
+
+DROP TABLE pending_deletes;
+ALTER TABLE pending_deletes_new RENAME TO pending_deletes;
+
+CREATE TABLE pending_archives_new (
+ message TEXT NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
+ PRIMARY KEY (message)
+) STRICT;
+
+INSERT INTO pending_archives_new (message)
+SELECT m.id FROM pending_archives p JOIN messages m ON m.old = p.message;
+
+DROP TABLE pending_archives;
+ALTER TABLE pending_archives_new RENAME TO pending_archives;
+
+CREATE UNIQUE INDEX threads_account_jmap_thread ON threads (account, jmap_thread_id);
+CREATE INDEX messages_thread ON messages (thread);
+CREATE INDEX messages_state ON messages (state);
+CREATE INDEX comments_thread ON comments (thread);
+CREATE INDEX history_message ON history (message);
+
+ALTER TABLE threads DROP COLUMN old;
+ALTER TABLE messages DROP COLUMN old;
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 4aec016..ea63ef3 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -7,15 +7,36 @@ use std::collections::BTreeSet;
use jiff::civil::{DateTime, date};
use crate::Error;
-use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
+use crate::model::{Account, Comment, Kind, Message, State, Thread, User, Values, reverse_hex};
use crate::store::{Clock, Store};
-pub const WATER: MessageId = 7;
-pub const ELI_PRACTICE: MessageId = 20;
+/// Seeds for the rows tests name by number.
+pub const WATER: u32 = 7;
+pub const ELI_PRACTICE: u32 = 20;
pub const ALEX: &str = "alex@example.com";
pub const SAM: &str = "sam@example.com";
+/// The fixture row id for a seed number: the seed hashed (splitmix64)
+/// and rendered as jj-style reverse hex, so rows keep stable jj-looking
+/// ids while the fixtures stay written in numbers.
+pub fn id(seed: u32) -> String {
+ reverse_hex(splitmix64(u64::from(seed)))
+}
+
+/// The fixture Message-ID for a seed number.
+pub fn message_id(seed: u32) -> String {
+ format!("{}@fixtures.docket.invalid", id(seed))
+}
+
+fn splitmix64(mut x: u64) -> u64 {
+ x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = x;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+}
+
/// Thursday, 1 October 2026, at noon.
pub fn now() -> DateTime {
at(10, 1, 12, 0)
@@ -25,10 +46,6 @@ fn at(month: i8, day: i8, hour: i8, minute: i8) -> DateTime {
date(2026, month, day).at(hour, minute, 0, 0)
}
-fn message_id(id: MessageId) -> String {
- format!("{id}@fixtures.docket.invalid")
-}
-
fn strings(xs: &[&str]) -> Vec<String> {
xs.iter().map(|&x| x.to_owned()).collect()
}
@@ -41,9 +58,9 @@ struct Builder {
}
impl Builder {
- fn thread(&mut self, id: u32, account: &str, subject: &str) {
+ fn thread(&mut self, seed: u32, account: &str, subject: &str) {
self.threads.push(Thread {
- id,
+ id: id(seed),
account: account.to_owned(),
subject: subject.to_owned(),
});
@@ -52,7 +69,7 @@ impl Builder {
#[allow(clippy::too_many_arguments)]
fn recv(
&mut self,
- id: MessageId,
+ seed: u32,
thread: u32,
at: DateTime,
(from, addr): (&str, &str),
@@ -61,9 +78,9 @@ impl Builder {
cc: &[&str],
) {
self.messages.push(Message {
- id,
- message_id: message_id(id),
- thread,
+ id: id(seed),
+ message_id: message_id(seed),
+ thread: id(thread),
at,
cc: strings(cc),
bcc: Vec::new(),
@@ -85,7 +102,7 @@ impl Builder {
fn sent(
&mut self,
- id: MessageId,
+ seed: u32,
thread: u32,
at: DateTime,
(by, to): (&str, &[&str]),
@@ -93,9 +110,9 @@ impl Builder {
(cc, bcc): (&[&str], &[&str]),
) {
self.messages.push(Message {
- id,
- message_id: message_id(id),
- thread,
+ id: id(seed),
+ message_id: message_id(seed),
+ thread: id(thread),
at,
cc: strings(cc),
bcc: strings(bcc),
@@ -108,12 +125,12 @@ impl Builder {
}
fn comment(&mut self, thread: u32, author: &str, at: DateTime, text: &str) {
- let id = u32::try_from(self.comments.len())
+ let n = u32::try_from(self.comments.len())
.unwrap_or(u32::MAX)
.saturating_add(1);
self.comments.push(Comment {
- id,
- thread,
+ id: id(n),
+ thread: id(thread),
author: author.to_owned(),
at,
text: text.to_owned(),
@@ -330,9 +347,9 @@ pub fn seed(store: &Store) -> Result<(), Error> {
.messages
.iter()
.filter(|m| m.at < cutoff)
- .flat_map(|m| [(ALEX, m.id), (SAM, m.id)])
+ .flat_map(|m| [(ALEX, m.id.clone()), (SAM, m.id.clone())])
.collect();
- reads.push((SAM, 5));
+ reads.push((SAM, id(5)));
store.import(|tx| {
for user in &users {
@@ -354,7 +371,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
tx.comment(comment)?;
}
for (user, id) in reads {
- tx.read(user, id)?;
+ tx.read(user, &id)?;
}
Ok(())
})
diff --git a/src/jmap.rs b/src/jmap.rs
index a7b18b8..5e92729 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -1446,7 +1446,7 @@ impl Client {
for p in &pending {
let Some(memberships) = current.get(&p.jmap_id) else {
tracing::warn!(jmap_id = %p.jmap_id, "filing dropped: the mail is gone server-side");
- clear.push(p.message);
+ clear.push(p.message.clone());
continue;
};
if p.folder
@@ -1454,7 +1454,7 @@ impl Client {
.is_some_and(|f| layout.folder_id(f).is_none())
{
tracing::warn!(?p.folder, "filing dropped: the folder vanished server-side");
- clear.push(p.message);
+ clear.push(p.message.clone());
continue;
}
if !filing_allowed(memberships, layout, p.folder.as_deref()) {
@@ -1482,7 +1482,10 @@ impl Client {
// Refused ids clear too: the server's verdict stands, and
// retrying would only requeue the refusal.
clear.extend(chunk.iter().filter_map(|(id, _)| {
- pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)
+ pending
+ .iter()
+ .find(|p| &p.jmap_id == id)
+ .map(|p| p.message.clone())
}));
}
store.clear_pending_files(&clear)?;
@@ -1528,7 +1531,7 @@ impl Client {
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");
- clear.push(p.message);
+ clear.push(p.message.clone());
continue;
};
let Some(next) = archived_mailboxes(memberships, layout) else {
@@ -1538,7 +1541,7 @@ impl Client {
if layout.archive.is_none() {
tracing::warn!(jmap_id = %p.jmap_id, "archive dropped: the server has no Archive mailbox");
}
- clear.push(p.message);
+ clear.push(p.message.clone());
continue;
};
if !archive_allowed(layout) {
@@ -1561,7 +1564,10 @@ impl Client {
// Refused ids clear too: the server's verdict stands, and
// retrying would only requeue the refusal.
clear.extend(chunk.iter().filter_map(|(id, _)| {
- pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)
+ pending
+ .iter()
+ .find(|p| &p.jmap_id == id)
+ .map(|p| p.message.clone())
}));
}
store.clear_pending_archives(&clear)?;
@@ -1598,11 +1604,12 @@ impl Client {
deleted = deleted.saturating_add(reply.destroyed.len());
// Refused ids clear too: the server's verdict stands, and
// retrying would only requeue the refusal.
- clear.extend(
- chunk
+ clear.extend(chunk.iter().filter_map(|id| {
+ pending
.iter()
- .filter_map(|id| pending.iter().find(|p| &p.jmap_id == id).map(|p| p.message)),
- );
+ .find(|p| &p.jmap_id == id)
+ .map(|p| p.message.clone())
+ }));
}
store.clear_pending_deletes(&clear)?;
Ok(deleted)
diff --git a/src/lists.rs b/src/lists.rs
index 791080c..33bc7c8 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -101,7 +101,7 @@ fn section(
for m in msgs {
if let Some(g) = groups.iter_mut().find(|g| g.thread.id == m.thread) {
g.rows.push(m);
- } else if let Some(thread) = store.thread(m.thread)? {
+ } else if let Some(thread) = store.thread(&m.thread)? {
groups.push(Group {
thread,
rows: vec![m],
@@ -125,8 +125,9 @@ fn section(
mod tests {
use super::*;
use crate::fixtures::{self, ALEX, SAM};
+ use crate::model::MessageId;
- fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<u32>>)> {
+ fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<MessageId>>)> {
let me = store.user(user).unwrap().unwrap();
sections(store, &me, view)
.unwrap()
@@ -136,7 +137,7 @@ mod tests {
s.head,
s.groups
.iter()
- .map(|g| g.rows.iter().map(|m| m.id).collect())
+ .map(|g| g.rows.iter().map(|m| m.id.clone()).collect())
.collect(),
)
})
@@ -157,15 +158,22 @@ mod tests {
assert_eq!(heads(&store, SAM, &View::ForMe), ["INBOX", "DO", "WATCH"]);
let (_, groups) = &sam[0];
// Newest thread first; the roofing thread shows only its two Inbox emails.
- assert_eq!(groups[0], vec![fixtures::WATER]);
- assert!(groups.contains(&vec![3, 4]));
+ assert_eq!(groups[0], vec![fixtures::id(fixtures::WATER)]);
+ assert!(groups.contains(&vec![fixtures::id(3), fixtures::id(4)]));
// Alex's own Inbox messages share the section with unassigned ones.
let alex = summary(&store, ALEX, &View::ForMe);
assert_eq!(heads(&store, ALEX, &View::ForMe), ["INBOX", "WAIT"]);
assert_eq!(
alex[0].1,
- [vec![7], vec![6], vec![10], vec![8], vec![9], vec![3, 4]]
+ [
+ vec![fixtures::id(7)],
+ vec![fixtures::id(6)],
+ vec![fixtures::id(10)],
+ vec![fixtures::id(8)],
+ vec![fixtures::id(9)],
+ vec![fixtures::id(3), fixtures::id(4)]
+ ]
);
}
@@ -183,17 +191,17 @@ mod tests {
// oldest first.
let inbox = summary(&store, SAM, &View::Lane(State::Inbox));
assert_eq!(heads(&store, SAM, &View::Lane(State::Inbox)), [""]);
- assert_eq!(inbox[0].1[0], vec![fixtures::WATER]);
+ assert_eq!(inbox[0].1[0], vec![fixtures::id(fixtures::WATER)]);
let todo = summary(&store, ALEX, &View::Lane(State::Do));
assert_eq!(heads(&store, ALEX, &View::Lane(State::Do)), [""]);
- assert_eq!(todo[0].1, [vec![13], vec![5]]);
+ assert_eq!(todo[0].1, [vec![fixtures::id(13)], vec![fixtures::id(5)]]);
let wait = summary(&store, SAM, &View::Lane(State::Wait));
- assert_eq!(wait[0].1, vec![vec![11]]);
+ assert_eq!(wait[0].1, vec![vec![fixtures::id(11)]]);
let me = store.user(SAM).unwrap().unwrap();
let watch = sections(&store, &me, &View::Lane(State::Watch)).unwrap();
assert!(watch[0].compact);
- assert_eq!(watch[0].groups[0].rows[0].id, 14);
+ assert_eq!(watch[0].groups[0].rows[0].id, fixtures::id(14));
assert!(
sections(&store, &me, &View::Lane(State::Done))
.unwrap()
@@ -206,11 +214,11 @@ mod tests {
fn search_covers_every_message() {
let store = fixtures::store().unwrap();
let hits = summary(&store, SAM, &View::Search("downspout".into()));
- assert_eq!(hits[0].1, vec![vec![2, 4]]);
+ assert_eq!(hits[0].1, vec![vec![fixtures::id(2), fixtures::id(4)]]);
let by_sender = summary(&store, SAM, &View::Search("ALEX".into()));
- assert_eq!(by_sender[0].1, vec![vec![12]]);
+ assert_eq!(by_sender[0].1, vec![vec![fixtures::id(12)]]);
let by_subject = summary(&store, SAM, &View::Search("checkup".into()));
- assert_eq!(by_subject[0].1, vec![vec![15]]);
+ assert_eq!(by_subject[0].1, vec![vec![fixtures::id(15)]]);
assert!(summary(&store, SAM, &View::Search(" ".into())).is_empty());
assert!(summary(&store, SAM, &View::Search("zzz".into())).is_empty());
}
diff --git a/src/model.rs b/src/model.rs
index 389d9e9..817340b 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -7,9 +7,27 @@ use std::fmt;
use jiff::civil::DateTime;
-pub type MessageId = u32;
-pub type ThreadId = u32;
-pub type CommentId = u32;
+pub type MessageId = String;
+pub type ThreadId = String;
+pub type CommentId = String;
+
+/// The reverse-hex alphabet jj's change ids use: nibble `0` renders as
+/// `z`, so the ids carry no order and no meaning.
+pub(crate) const REVERSE_HEX: [char; 16] = [
+ 'z', 'y', 'x', 'w', 'v', 'u', 't', 's', 'r', 'q', 'p', 'o', 'n', 'm', 'l', 'k',
+];
+
+pub(crate) fn reverse_hex(bits: u64) -> String {
+ reverse_hex_bytes(&bits.to_be_bytes())
+}
+
+pub(crate) fn reverse_hex_bytes(bytes: &[u8]) -> String {
+ bytes
+ .iter()
+ .flat_map(|b| [b >> 4, b & 0xf])
+ .filter_map(|nibble| REVERSE_HEX.get(usize::from(nibble)).copied())
+ .collect()
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum State {
diff --git a/src/routes.rs b/src/routes.rs
index b63fa09..ca087d6 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -138,7 +138,10 @@ fn render(
here: &str,
) -> Result<Markup, Error> {
let store = &state.store;
- let thread = selected.map(|id| open_thread(store, me, id)).transpose()?;
+ let thread = selected
+ .clone()
+ .map(|id| open_thread(store, me, &id))
+ .transpose()?;
let nav = std::iter::once(View::ForMe)
.chain(State::LANES.map(View::Lane))
.map(|v| lists::count(store, me, &v).map(|n| (v, n)))
@@ -161,20 +164,22 @@ fn render(
/// Opening a thread reads the selected message and the latest one, the two
/// that open expanded.
-fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Error> {
+fn open_thread(store: &Store, me: &User, id: &str) -> Result<ThreadView, Error> {
let msg = store.message(id)?.ok_or(Error::NotFound("message"))?;
let latest = store
- .thread_messages(msg.thread)?
+ .thread_messages(&msg.thread)?
.last()
- .map_or(id, |m| m.id);
+ .map_or_else(|| id.to_owned(), |m| m.id.clone());
store.mark_read(&me.login, id)?;
- store.mark_read(&me.login, latest)?;
+ store.mark_read(&me.login, &latest)?;
Ok(ThreadView {
- thread: store.thread(msg.thread)?.ok_or(Error::NotFound("thread"))?,
+ thread: store
+ .thread(&msg.thread)?
+ .ok_or(Error::NotFound("thread"))?,
account: store
- .thread_account(msg.thread)?
+ .thread_account(&msg.thread)?
.ok_or(Error::NotFound("account"))?,
- timeline: store.timeline(msg.thread)?,
+ timeline: store.timeline(&msg.thread)?,
latest,
})
}
@@ -249,7 +254,7 @@ async fn edit(
change: Change,
back: &str,
) -> Result<Redirect, Error> {
- state.store.edit(&me.login, id, change)?;
+ state.store.edit(&me.login, &id, change)?;
Ok(Redirect::to(safe_back(back)))
}
@@ -259,7 +264,7 @@ async fn delete_message(
Path(id): Path<MessageId>,
Form(form): Form<BackForm>,
) -> Result<Redirect, Error> {
- state.store.delete(&me.login, id)?;
+ state.store.delete(&me.login, &id)?;
Ok(Redirect::to(safe_back(&form.back)))
}
@@ -275,7 +280,7 @@ async fn add_comment(
Path(thread): Path<ThreadId>,
Form(form): Form<CommentForm>,
) -> Result<Redirect, Error> {
- state.store.add_comment(&me.login, thread, &form.text)?;
+ state.store.add_comment(&me.login, &thread, &form.text)?;
Ok(Redirect::to(safe_back(&form.back)))
}
diff --git a/src/store.rs b/src/store.rs
index 5e1433f..a306f06 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -14,8 +14,7 @@ use rusqlite::{Connection, OptionalExtension as _, Params, Row, ToSql, Transacti
use crate::Error;
use crate::model::{
- Account, Comment, CommentId, Event, Kind, Message, MessageId, State, Thread, ThreadId, User,
- Values,
+ Account, Comment, Event, Kind, Message, MessageId, State, Thread, User, Values,
};
/// Applied in order; `PRAGMA user_version` counts how many have run.
@@ -25,6 +24,7 @@ const MIGRATIONS: &[&str] = &[
include_str!("../migrations/0003_pending_files.sql"),
include_str!("../migrations/0004_pending_deletes.sql"),
include_str!("../migrations/0005_pending_archives.sql"),
+ include_str!("../migrations/0006_jj_style_ids.sql"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -208,7 +208,7 @@ impl Store {
Ok(found)
}
- pub fn thread(&self, id: ThreadId) -> Result<Option<Thread>, Error> {
+ pub fn thread(&self, id: &str) -> Result<Option<Thread>, Error> {
let thread = self
.lock()
.conn
@@ -227,16 +227,16 @@ impl Store {
Ok(thread)
}
- pub fn thread_account(&self, thread: ThreadId) -> Result<Option<Account>, Error> {
+ pub fn thread_account(&self, thread: &str) -> Result<Option<Account>, Error> {
load_thread_account(&self.lock().conn, thread)
}
- pub fn message(&self, id: MessageId) -> Result<Option<Message>, Error> {
+ pub fn message(&self, id: &str) -> Result<Option<Message>, Error> {
load_message(&self.lock().conn, id)
}
/// The thread's messages, oldest first.
- pub fn thread_messages(&self, thread: ThreadId) -> Result<Vec<Message>, Error> {
+ pub fn thread_messages(&self, thread: &str) -> Result<Vec<Message>, Error> {
load_messages(&self.lock().conn, "m.thread = ?1", [thread])
}
@@ -270,11 +270,11 @@ impl Store {
/// The thread's messages and comments in time order. A message sorts
/// before a comment made at the same moment.
- pub fn timeline(&self, thread: ThreadId) -> Result<Vec<Item>, Error> {
+ pub fn timeline(&self, thread: &str) -> Result<Vec<Item>, Error> {
let inner = self.lock();
let messages = load_messages(&inner.conn, "m.thread = ?1", [thread])?;
let mut stmt = inner.conn.prepare_cached(
- "SELECT id, thread, author, at, text FROM comments WHERE thread = ?1 ORDER BY at, id",
+ "SELECT id, thread, author, at, text FROM comments WHERE thread = ?1 ORDER BY at, rowid",
)?;
let comments = stmt
.query_map([thread], |r| {
@@ -312,15 +312,15 @@ impl Store {
Ok(ids)
}
- pub fn mark_read(&self, user: &str, msg: MessageId) -> Result<(), Error> {
+ pub fn mark_read(&self, user: &str, msg: &str) -> Result<(), Error> {
insert_read(&self.lock().conn, user, msg)
}
/// Every change made to the message's values, oldest first.
- pub fn history(&self, message: MessageId) -> Result<Vec<Event>, Error> {
+ pub fn history(&self, message: &str) -> Result<Vec<Event>, Error> {
let inner = self.lock();
let mut stmt = inner.conn.prepare_cached(
- "SELECT message, user, at, event FROM history WHERE message = ?1 ORDER BY id",
+ "SELECT message, user, at, event FROM history WHERE message = ?1 ORDER BY rowid",
)?;
let events = stmt
.query_map([message], |r| {
@@ -335,11 +335,11 @@ impl Store {
Ok(events)
}
- pub fn edit(&self, user: &str, id: MessageId, change: Change) -> Result<(), Error> {
+ pub fn edit(&self, user: &str, id: &str, change: Change) -> Result<(), Error> {
let mut inner = self.lock();
let tx = inner.conn.transaction()?;
let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
- let read_only = load_thread_account(&tx, msg.thread)?.is_some_and(|a| a.read_only);
+ let read_only = load_thread_account(&tx, &msg.thread)?.is_some_and(|a| a.read_only);
let Kind::Received { values, .. } = msg.kind else {
return Err(Error::BadRequest("sent messages have no values"));
};
@@ -398,7 +398,7 @@ impl Store {
tx.commit()?;
inner
.undo
- .insert(user.to_owned(), (id, prev, queued_archive));
+ .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()
@@ -427,18 +427,18 @@ impl Store {
// 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])?;
+ tx.execute("DELETE FROM pending_archives WHERE message = ?1", [&id])?;
}
// An undone filing reverts server-side too: the queued intent
// points back at the folder the undo restores.
- let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
+ let msg = load_message(&tx, &id)?.ok_or(Error::NotFound("message"))?;
if let Kind::Received { values, .. } = &msg.kind
&& values.folder != prev.folder
{
- queue_file(&tx, id, prev.folder.as_deref())?;
+ queue_file(&tx, &id, prev.folder.as_deref())?;
}
- write_values(&tx, id, &prev)?;
- insert_event(&tx, id, user, self.now(), "Undone")?;
+ write_values(&tx, &id, &prev)?;
+ insert_event(&tx, &id, user, self.now(), "Undone")?;
tx.commit()?;
inner.undo.remove(user);
inner.flash.insert(
@@ -491,11 +491,11 @@ impl Store {
/// 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.
- pub fn delete(&self, user: &str, id: MessageId) -> Result<(), Error> {
+ pub fn delete(&self, user: &str, id: &str) -> Result<(), Error> {
let mut inner = self.lock();
let tx = inner.conn.transaction()?;
let msg = load_message(&tx, id)?.ok_or(Error::NotFound("message"))?;
- let read_only = load_thread_account(&tx, msg.thread)?.is_some_and(|a| a.read_only);
+ let read_only = load_thread_account(&tx, &msg.thread)?.is_some_and(|a| a.read_only);
if read_only {
return Err(Error::Forbidden("this account is read-only"));
}
@@ -601,7 +601,7 @@ impl Store {
self.lock().flash.remove(user)
}
- pub fn add_comment(&self, user: &str, thread: ThreadId, text: &str) -> Result<(), Error> {
+ pub fn add_comment(&self, user: &str, thread: &str, text: &str) -> Result<(), Error> {
let text = text.trim();
if text.is_empty() {
return Err(Error::BadRequest("comment is empty"));
@@ -788,16 +788,16 @@ impl Import<'_> {
return Err(Error::NotFound("thread"));
}
if let Some(values) = m.values() {
- write_values(&self.tx, m.id, values)?;
+ write_values(&self.tx, &m.id, values)?;
}
Ok(())
}
pub fn comment(&self, c: &Comment) -> Result<(), Error> {
- insert_comment(&self.tx, Some(c.id), c.thread, &c.author, c.at, &c.text)
+ insert_comment(&self.tx, Some(&c.id), &c.thread, &c.author, c.at, &c.text)
}
- pub fn read(&self, user: &str, msg: MessageId) -> Result<(), Error> {
+ pub fn read(&self, user: &str, msg: &str) -> Result<(), Error> {
insert_read(&self.tx, user, msg)
}
@@ -809,9 +809,15 @@ impl Import<'_> {
/// refreshes.
pub fn incoming(&self, account: &str, mail: &Incoming) -> Result<(), Error> {
self.tx.execute(
- "INSERT INTO threads (account, subject, jmap_thread_id) VALUES (?1, ?2, ?3)
+ "INSERT INTO threads (id, account, subject, jmap_thread_id)
+ VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (account, jmap_thread_id) DO NOTHING",
- params![account, mail.subject, mail.jmap_thread_id],
+ params![
+ fresh_id(&self.tx)?,
+ account,
+ mail.subject,
+ mail.jmap_thread_id
+ ],
)?;
let (kind, from_name, from_addr, state, folder, sent_by, sent_to) = match &mail.kind {
IncomingKind::Received {
@@ -841,10 +847,10 @@ impl Import<'_> {
let cc = json(&mail.cc)?;
let bcc = json(&mail.bcc)?;
let _upserted = self.tx.execute(
- "INSERT INTO messages (account, message_id, jmap_id, thread, at, cc, bcc, body,
+ "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, t.id, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15
- FROM threads t WHERE t.account = ?1 AND t.jmap_thread_id = ?4
+ SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16
+ FROM threads t WHERE t.account = ?2 AND t.jmap_thread_id = ?5
ON CONFLICT (account, message_id) DO UPDATE SET
jmap_id = excluded.jmap_id,
thread = excluded.thread,
@@ -858,6 +864,7 @@ impl Import<'_> {
sent_by = excluded.sent_by,
sent_to = excluded.sent_to",
params![
+ fresh_id(&self.tx)?,
account,
mail.message_id,
mail.jmap_id,
@@ -929,7 +936,7 @@ fn load_user(conn: &Connection, login: &str) -> Result<Option<User>, Error> {
Ok(user)
}
-fn load_thread_account(conn: &Connection, thread: ThreadId) -> Result<Option<Account>, Error> {
+fn load_thread_account(conn: &Connection, thread: &str) -> Result<Option<Account>, Error> {
let account = conn
.query_row(
"SELECT a.slug, a.name, a.address, a.read_only
@@ -959,7 +966,7 @@ fn folder_exists(conn: &Connection, name: &str) -> Result<bool, Error> {
Ok(exists)
}
-fn load_message(conn: &Connection, id: MessageId) -> Result<Option<Message>, Error> {
+fn load_message(conn: &Connection, id: &str) -> Result<Option<Message>, Error> {
Ok(load_messages(conn, "m.id = ?1", [id])?.into_iter().next())
}
@@ -1017,7 +1024,7 @@ fn message_from_row(r: &Row<'_>) -> rusqlite::Result<Message> {
})
}
-fn write_values(conn: &Connection, id: MessageId, values: &Values) -> Result<(), Error> {
+fn write_values(conn: &Connection, id: &str, values: &Values) -> Result<(), Error> {
conn.execute(
"UPDATE messages SET state = ?2, folder = ?3 WHERE id = ?1",
params![id, values.state, values.folder],
@@ -1035,7 +1042,7 @@ fn write_values(conn: &Connection, id: MessageId, values: &Values) -> Result<(),
/// Records a filing for the poll loop to push (task rn). Fixture mail
/// has no server side to move, so rows without a cached JMAP id stay
/// out.
-fn queue_file(conn: &Connection, id: MessageId, folder: Option<&str>) -> Result<(), Error> {
+fn queue_file(conn: &Connection, id: &str, folder: Option<&str>) -> Result<(), Error> {
conn.execute(
"INSERT INTO pending_files (message, folder)
SELECT ?1, ?2 FROM messages WHERE id = ?1 AND jmap_id IS NOT NULL
@@ -1048,7 +1055,7 @@ fn queue_file(conn: &Connection, id: MessageId, folder: Option<&str>) -> Result<
/// 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: MessageId) -> Result<(), Error> {
+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
@@ -1060,34 +1067,34 @@ fn queue_archive(conn: &Connection, id: MessageId) -> Result<(), Error> {
fn insert_event(
conn: &Connection,
- message: MessageId,
+ message: &str,
user: &str,
at: DateTime,
text: &str,
) -> Result<(), Error> {
conn.execute(
- "INSERT INTO history (message, user, at, event) VALUES (?1, ?2, ?3, ?4)",
- params![message, user, at, text],
+ "INSERT INTO history (id, message, user, at, event) VALUES (?1, ?2, ?3, ?4, ?5)",
+ params![fresh_id(conn)?, message, user, at, text],
)?;
Ok(())
}
fn insert_comment(
conn: &Connection,
- id: Option<CommentId>,
- thread: ThreadId,
+ id: Option<&str>,
+ thread: &str,
author: &str,
at: DateTime,
text: &str,
) -> Result<(), Error> {
conn.execute(
"INSERT INTO comments (id, thread, author, at, text) VALUES (?1, ?2, ?3, ?4, ?5)",
- params![id, thread, author, at, text],
+ params![id.unwrap_or(&fresh_id(conn)?), thread, author, at, text],
)?;
Ok(())
}
-fn insert_read(conn: &Connection, user: &str, msg: MessageId) -> Result<(), Error> {
+fn insert_read(conn: &Connection, user: &str, msg: &str) -> Result<(), Error> {
conn.execute(
"INSERT OR IGNORE INTO reads (user, message) VALUES (?1, ?2)",
params![user, msg],
@@ -1095,6 +1102,14 @@ fn insert_read(conn: &Connection, user: &str, msg: MessageId) -> Result<(), Erro
Ok(())
}
+/// A fresh row id: 64 random bits as jj-style reverse hex — jj's
+/// change-id alphabet, where nibble `0` renders as `z` — giving 16
+/// characters that carry no order and no meaning.
+fn fresh_id(conn: &Connection) -> Result<String, Error> {
+ let bytes: Vec<u8> = conn.query_row("SELECT randomblob(8)", [], |r| r.get(0))?;
+ Ok(crate::model::reverse_hex_bytes(&bytes))
+}
+
/// Lists of names and addresses are stored as JSON arrays.
fn json<T: serde::Serialize>(value: &T) -> rusqlite::Result<String> {
serde_json::to_string(value).map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))
@@ -1124,7 +1139,7 @@ mod tests {
use super::*;
use crate::fixtures::{self, ALEX, SAM};
- fn values(store: &Store, id: MessageId) -> Values {
+ fn values(store: &Store, id: &str) -> Values {
store
.message(id)
.unwrap()
@@ -1137,8 +1152,10 @@ mod tests {
#[test]
fn edits_record_undo_flash_and_history() {
let store = fixtures::store().unwrap();
- store.edit(SAM, 4, Change::State(State::Do)).unwrap();
- assert_eq!(values(&store, 4).state, State::Do);
+ store
+ .edit(SAM, &fixtures::id(4), Change::State(State::Do))
+ .unwrap();
+ assert_eq!(values(&store, &fixtures::id(4)).state, State::Do);
assert_eq!(
store.take_flash(SAM),
Some(Flash {
@@ -1149,12 +1166,12 @@ mod tests {
assert_eq!(store.take_flash(SAM), None);
store.undo(SAM).unwrap();
- assert_eq!(values(&store, 4).state, State::Inbox);
+ assert_eq!(values(&store, &fixtures::id(4)).state, State::Inbox);
assert_eq!(store.take_flash(SAM).unwrap().text, "Undone");
assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
let history: Vec<_> = store
- .history(4)
+ .history(&fixtures::id(4))
.unwrap()
.into_iter()
.map(|e| (e.user, e.at, e.text))
@@ -1188,18 +1205,19 @@ mod tests {
.unwrap()
.first()
.unwrap()
- .id;
+ .id
+ .clone();
store.sign_in(SAM, "Sam").unwrap();
// Filing queues the intent, and the latest one wins.
store
- .edit(SAM, id, Change::Folder(Some("House".into())))
+ .edit(SAM, &id, Change::Folder(Some("House".into())))
.unwrap();
- store.edit(SAM, id, Change::Folder(None)).unwrap();
+ store.edit(SAM, &id, Change::Folder(None)).unwrap();
assert_eq!(
store.pending_files("household").unwrap(),
[PendingFile {
- message: id,
+ message: id.clone(),
jmap_id: "E9".into(),
folder: None,
}]
@@ -1209,13 +1227,13 @@ mod tests {
assert!(store.pending_files("eli").unwrap().is_empty());
// What the poll loop pushed, it clears.
- store.clear_pending_files(&[id]).unwrap();
+ store.clear_pending_files(&[id.clone()]).unwrap();
assert!(store.pending_files("household").unwrap().is_empty());
store.clear_pending_files(&[]).unwrap();
// Undoing a filing re-points the queue at the restored folder.
store
- .edit(SAM, id, Change::Folder(Some("House".into())))
+ .edit(SAM, &id, Change::Folder(Some("House".into())))
.unwrap();
assert_eq!(
store.pending_files("household").unwrap()[0].folder,
@@ -1231,11 +1249,11 @@ mod tests {
"CREATE TRIGGER no_pending BEFORE INSERT ON pending_files BEGIN SELECT RAISE(ABORT, 'no'); END;",
);
assert!(matches!(
- store.edit(SAM, id, Change::Folder(Some("House".into()))),
+ store.edit(SAM, &id, Change::Folder(Some("House".into()))),
Err(Error::Db(_))
));
exec(&store, "DROP TRIGGER no_pending;");
- assert_eq!(values(&store, id).folder, None);
+ assert_eq!(values(&store, &id).folder, None);
}
#[test]
@@ -1251,7 +1269,11 @@ mod tests {
fn fixture_mail_never_queues_a_filing() {
let store = fixtures::store().unwrap();
store
- .edit(SAM, 4, Change::Folder(Some("Finance".into())))
+ .edit(
+ SAM,
+ &fixtures::id(4),
+ Change::Folder(Some("Finance".into())),
+ )
.unwrap();
// No server side to move, so no row: fixture mail has no cached
// JMAP id.
@@ -1262,14 +1284,23 @@ mod tests {
fn folders() {
let store = fixtures::store().unwrap();
store
- .edit(SAM, 4, Change::Folder(Some("Finance".into())))
+ .edit(
+ SAM,
+ &fixtures::id(4),
+ Change::Folder(Some("Finance".into())),
+ )
+ .unwrap();
+ assert_eq!(
+ values(&store, &fixtures::id(4)).folder.as_deref(),
+ Some("Finance")
+ );
+ store
+ .edit(SAM, &fixtures::id(4), Change::Folder(None))
.unwrap();
- assert_eq!(values(&store, 4).folder.as_deref(), Some("Finance"));
- store.edit(SAM, 4, Change::Folder(None)).unwrap();
- assert_eq!(values(&store, 4).folder, None);
+ assert_eq!(values(&store, &fixtures::id(4)).folder, None);
assert_eq!(store.take_flash(SAM).unwrap().text, "Removed from folder");
assert!(matches!(
- store.edit(SAM, 4, Change::Folder(Some("Nope".into()))),
+ store.edit(SAM, &fixtures::id(4), Change::Folder(Some("Nope".into()))),
Err(Error::NotFound("folder"))
));
assert_eq!(
@@ -1297,17 +1328,18 @@ mod tests {
.unwrap()
.first()
.unwrap()
- .id;
+ .id
+ .clone();
store.sign_in(SAM, "Sam").unwrap();
// Done on a writable account queues the archive; 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);
+ 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 {
- message: id,
+ message: id.clone(),
jmap_id: "E9".into(),
}]
);
@@ -1319,7 +1351,7 @@ mod tests {
}
);
let history: Vec<_> = store
- .history(id)
+ .history(&id)
.unwrap()
.into_iter()
.map(|e| (e.user, e.text))
@@ -1328,20 +1360,20 @@ mod tests {
// Undo reverts the state and cancels the queued exit.
store.undo(SAM).unwrap();
- assert_eq!(values(&store, id).state, State::Inbox);
+ assert_eq!(values(&store, &id).state, State::Inbox);
assert!(store.pending_archives("household").unwrap().is_empty());
// Another account's drain never sees them; what the poll
// pushed, it clears.
- store.edit(SAM, id, Change::State(State::Done)).unwrap();
+ store.edit(SAM, &id, Change::State(State::Done)).unwrap();
assert!(store.pending_archives("eli").unwrap().is_empty());
- store.clear_pending_archives(&[id]).unwrap();
+ store.clear_pending_archives(&[id.clone()]).unwrap();
assert!(store.pending_archives("household").unwrap().is_empty());
store.clear_pending_archives(&[]).unwrap();
// Deleting a Done message supersedes its queued archive.
- store.edit(SAM, id, Change::State(State::Done)).unwrap();
- store.delete(SAM, id).unwrap();
+ store.edit(SAM, &id, Change::State(State::Done)).unwrap();
+ store.delete(SAM, &id).unwrap();
assert!(store.pending_archives("household").unwrap().is_empty());
assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
@@ -1351,13 +1383,13 @@ mod tests {
&store,
"CREATE TRIGGER no_pending_archive BEFORE INSERT ON pending_archives BEGIN SELECT RAISE(ABORT, 'no'); END;",
);
- store.edit(SAM, id, Change::State(State::Inbox)).unwrap();
+ store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
assert!(matches!(
- store.edit(SAM, id, Change::State(State::Done)),
+ 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);
+ assert_eq!(values(&store, &id).state, State::Inbox);
}
#[test]
@@ -1365,11 +1397,13 @@ mod tests {
// Read-only accounts and fixture mail mark Done without queueing
// anything.
let store = fixtures::store().unwrap();
- let eli = fixtures::ELI_PRACTICE;
- store.edit(SAM, eli, Change::State(State::Done)).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_eq!(store.take_flash(SAM).unwrap().text, "Moved to Done");
- store.edit(SAM, 4, Change::State(State::Done)).unwrap();
+ store
+ .edit(SAM, &fixtures::id(4), Change::State(State::Done))
+ .unwrap();
assert!(store.pending_archives("household").unwrap().is_empty());
}
@@ -1402,19 +1436,20 @@ mod tests {
.unwrap()
.first()
.unwrap()
- .id;
+ .id
+ .clone();
store.sign_in(SAM, "Sam").unwrap();
// A queued filing is superseded: the destroy replaces it.
store
- .edit(SAM, id, Change::Folder(Some("House".into())))
+ .edit(SAM, &id, Change::Folder(Some("House".into())))
.unwrap();
- store.delete(SAM, id).unwrap();
- assert_eq!(values(&store, id).state, State::Done);
+ store.delete(SAM, &id).unwrap();
+ assert_eq!(values(&store, &id).state, State::Done);
assert_eq!(
store.pending_deletes("household").unwrap(),
[PendingDelete {
- message: id,
+ message: id.clone(),
jmap_id: "E9".into(),
}]
);
@@ -1427,7 +1462,7 @@ mod tests {
})
);
let history: Vec<_> = store
- .history(id)
+ .history(&id)
.unwrap()
.into_iter()
.map(|e| (e.user, e.text))
@@ -1443,7 +1478,7 @@ mod tests {
// Another account's drain never sees them; what the poll
// pushed, it clears.
assert!(store.pending_deletes("eli").unwrap().is_empty());
- store.clear_pending_deletes(&[id]).unwrap();
+ store.clear_pending_deletes(&[id.clone()]).unwrap();
assert!(store.pending_deletes("household").unwrap().is_empty());
store.clear_pending_deletes(&[]).unwrap();
@@ -1452,10 +1487,10 @@ mod tests {
&store,
"CREATE TRIGGER no_pending_delete BEFORE INSERT ON pending_deletes BEGIN SELECT RAISE(ABORT, 'no'); END;",
);
- store.edit(SAM, id, Change::State(State::Inbox)).unwrap();
- assert!(matches!(store.delete(SAM, id), Err(Error::Db(_))));
+ store.edit(SAM, &id, Change::State(State::Inbox)).unwrap();
+ assert!(matches!(store.delete(SAM, &id), Err(Error::Db(_))));
exec(&store, "DROP TRIGGER no_pending_delete;");
- assert_eq!(values(&store, id).state, State::Inbox);
+ assert_eq!(values(&store, &id).state, State::Inbox);
}
#[test]
@@ -1470,13 +1505,13 @@ mod tests {
#[test]
fn read_only_accounts_cannot_file() {
let store = fixtures::store().unwrap();
- let eli = fixtures::ELI_PRACTICE;
+ let eli = fixtures::id(fixtures::ELI_PRACTICE);
assert!(matches!(
- store.edit(SAM, eli, Change::Folder(None)),
+ store.edit(SAM, &eli, Change::Folder(None)),
Err(Error::Forbidden(_))
));
- assert!(matches!(store.delete(SAM, eli), Err(Error::Forbidden(_))));
- store.edit(SAM, eli, Change::State(State::Done)).unwrap();
+ assert!(matches!(store.delete(SAM, &eli), Err(Error::Forbidden(_))));
+ store.edit(SAM, &eli, Change::State(State::Done)).unwrap();
}
#[test]
@@ -1484,28 +1519,31 @@ mod tests {
let store = fixtures::store().unwrap();
// Fixture mail has no server side to trash, so no row queues —
// but the thread still goes Done.
- store.delete(SAM, 4).unwrap();
- assert_eq!(values(&store, 4).state, State::Done);
+ store.delete(SAM, &fixtures::id(4)).unwrap();
+ assert_eq!(values(&store, &fixtures::id(4)).state, State::Done);
assert!(store.pending_deletes("household").unwrap().is_empty());
// Sent replies carry no values to transition.
- assert!(matches!(store.delete(SAM, 2), Err(Error::BadRequest(_))));
+ assert!(matches!(
+ store.delete(SAM, &fixtures::id(2)),
+ Err(Error::BadRequest(_))
+ ));
}
#[test]
fn assignees_toggle() {
let store = fixtures::store().unwrap();
store
- .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
+ .edit(ALEX, &fixtures::id(4), Change::ToggleAssignee(ALEX.into()))
.unwrap();
- assert!(values(&store, 4).assignees.contains(ALEX));
+ assert!(values(&store, &fixtures::id(4)).assignees.contains(ALEX));
assert_eq!(store.take_flash(ALEX).unwrap().text, "Assigned Alex");
store
- .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
+ .edit(ALEX, &fixtures::id(4), Change::ToggleAssignee(ALEX.into()))
.unwrap();
- assert!(values(&store, 4).assignees.is_empty());
+ assert!(values(&store, &fixtures::id(4)).assignees.is_empty());
assert_eq!(store.take_flash(ALEX).unwrap().text, "Unassigned Alex");
assert!(matches!(
- store.edit(ALEX, 4, Change::ToggleAssignee("eli".into())),
+ store.edit(ALEX, &fixtures::id(4), Change::ToggleAssignee("eli".into())),
Err(Error::NotFound("user"))
));
}
@@ -1514,11 +1552,11 @@ mod tests {
fn bad_edits() {
let store = fixtures::store().unwrap();
assert!(matches!(
- store.edit(SAM, 999, Change::State(State::Do)),
+ store.edit(SAM, &fixtures::id(999), Change::State(State::Do)),
Err(Error::NotFound("message"))
));
assert!(matches!(
- store.edit(SAM, 2, Change::State(State::Do)),
+ store.edit(SAM, &fixtures::id(2), Change::State(State::Do)),
Err(Error::BadRequest(_))
));
}
@@ -1526,17 +1564,19 @@ mod tests {
#[test]
fn comments() {
let store = fixtures::store().unwrap();
- let before = store.timeline(1).unwrap().len();
- store.add_comment(SAM, 1, " Called them. ").unwrap();
- let timeline = store.timeline(1).unwrap();
+ let before = store.timeline(&fixtures::id(1)).unwrap().len();
+ store
+ .add_comment(SAM, &fixtures::id(1), " Called them. ")
+ .unwrap();
+ let timeline = store.timeline(&fixtures::id(1)).unwrap();
assert_eq!(timeline.len(), before + 1);
assert!(matches!(timeline.last(), Some(Item::Comment(c)) if c.text == "Called them."));
assert!(matches!(
- store.add_comment(SAM, 1, " "),
+ store.add_comment(SAM, &fixtures::id(1), " "),
Err(Error::BadRequest(_))
));
assert!(matches!(
- store.add_comment(SAM, 999, "hi"),
+ store.add_comment(SAM, &fixtures::id(999), "hi"),
Err(Error::NotFound("thread"))
));
}
@@ -1545,7 +1585,7 @@ mod tests {
fn timeline_interleaves_comments_by_time() {
let store = fixtures::store().unwrap();
let order: Vec<_> = store
- .timeline(1)
+ .timeline(&fixtures::id(1))
.unwrap()
.into_iter()
.map(|item| match item {
@@ -1553,33 +1593,61 @@ mod tests {
Item::Comment(c) => format!("c{}", c.id),
})
.collect();
- assert_eq!(order, ["m1", "c1", "m2", "m3", "m4"]);
+ assert_eq!(
+ order,
+ [
+ format!("m{}", fixtures::id(1)),
+ format!("c{}", fixtures::id(1)),
+ format!("m{}", fixtures::id(2)),
+ format!("m{}", fixtures::id(3)),
+ format!("m{}", fixtures::id(4)),
+ ]
+ );
}
#[test]
fn read_tracking() {
let store = fixtures::store().unwrap();
- assert!(store.unread(ALEX).unwrap().contains(&fixtures::WATER));
- store.mark_read(ALEX, fixtures::WATER).unwrap();
- store.mark_read(ALEX, fixtures::WATER).unwrap();
- assert!(!store.unread(ALEX).unwrap().contains(&fixtures::WATER));
- assert!(store.unread(SAM).unwrap().contains(&fixtures::WATER));
+ assert!(
+ store
+ .unread(ALEX)
+ .unwrap()
+ .contains(&fixtures::id(fixtures::WATER))
+ );
+ store
+ .mark_read(ALEX, &fixtures::id(fixtures::WATER))
+ .unwrap();
+ store
+ .mark_read(ALEX, &fixtures::id(fixtures::WATER))
+ .unwrap();
+ assert!(
+ !store
+ .unread(ALEX)
+ .unwrap()
+ .contains(&fixtures::id(fixtures::WATER))
+ );
+ assert!(
+ store
+ .unread(SAM)
+ .unwrap()
+ .contains(&fixtures::id(fixtures::WATER))
+ );
// Sent messages are never unread.
- assert!(!store.unread(SAM).unwrap().contains(&2));
+ assert!(!store.unread(SAM).unwrap().contains(&fixtures::id(2)));
}
#[test]
fn messages_round_trip() {
let store = fixtures::store().unwrap();
- let sent = store.message(2).unwrap().unwrap();
- assert_eq!(sent.message_id, "2@fixtures.docket.invalid");
+ let sent = store.message(&fixtures::id(2)).unwrap().unwrap();
+ assert_eq!(sent.message_id, fixtures::message_id(2));
assert_eq!(sent.cc, ["Alex"]);
assert_eq!(sent.bcc, ["Pat Lee"]);
assert!(
matches!(&sent.kind, Kind::Sent { by, to } if by == &Some(SAM.to_owned())
&& to == &["Northwind Roofing".to_string()])
);
- let got = store.message(5).unwrap().unwrap();
+ let got = store.message(&fixtures::id(5)).unwrap().unwrap();
assert_eq!(got.at, jiff::civil::date(2026, 9, 30).at(15, 30, 0, 0));
assert_eq!(
got.values(),
@@ -1589,7 +1657,7 @@ mod tests {
assignees: BTreeSet::from([SAM.to_owned()]),
})
);
- assert!(store.message(999).unwrap().is_none());
+ assert!(store.message(&fixtures::id(999)).unwrap().is_none());
}
#[test]
@@ -1614,12 +1682,18 @@ mod tests {
assert_eq!(store.user(SAM).unwrap().unwrap().slug, "Sam");
assert!(store.user("pat@example.com").unwrap().is_none());
assert_eq!(
- store.thread(1).unwrap().unwrap().subject,
+ store.thread(&fixtures::id(1)).unwrap().unwrap().subject,
"Gutter repair estimate"
);
- assert!(store.thread(999).unwrap().is_none());
- assert!(store.thread_account(11).unwrap().unwrap().read_only);
- assert!(store.thread_account(999).unwrap().is_none());
+ assert!(store.thread(&fixtures::id(999)).unwrap().is_none());
+ assert!(
+ store
+ .thread_account(&fixtures::id(11))
+ .unwrap()
+ .unwrap()
+ .read_only
+ );
+ assert!(store.thread_account(&fixtures::id(999)).unwrap().is_none());
}
#[test]
@@ -1642,7 +1716,7 @@ mod tests {
store
.import(|tx| {
tx.thread(&Thread {
- id: 1,
+ id: fixtures::id(1),
account: "eli".into(),
subject: "Practice".into(),
})
@@ -1650,7 +1724,13 @@ mod tests {
.unwrap();
assert_eq!(store.accounts().unwrap(), [account]);
- assert!(!store.thread_account(1).unwrap().unwrap().read_only);
+ assert!(
+ !store
+ .thread_account(&fixtures::id(1))
+ .unwrap()
+ .unwrap()
+ .read_only
+ );
}
/// The shape of a JMAP delivery, for the incoming() tests.
@@ -1725,10 +1805,11 @@ mod tests {
.unwrap()
.first()
.unwrap()
- .id;
+ .id
+ .clone();
// Triaged in Docket after the import: the state moves here.
store.sign_in(ALEX, "Alex").unwrap();
- store.edit(ALEX, id, Change::State(State::Do)).unwrap();
+ store.edit(ALEX, &id, Change::State(State::Do)).unwrap();
// The next session reports a new body and no folder; the state we
// set survives, the server's fields refresh.
@@ -1736,12 +1817,47 @@ mod tests {
.import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "$2,120")))
.unwrap();
assert_eq!(store.messages(Filter::Search("2,120")).unwrap().len(), 1);
- let after = store.message(id).unwrap().unwrap();
+ let after = store.message(&id).unwrap().unwrap();
assert_eq!(after.body, "$2,120");
assert_eq!(after.values().unwrap().state, State::Do);
assert_eq!(after.values().unwrap().folder, None);
}
+ #[test]
+ fn incoming_reports_a_table_that_refuses_writes() {
+ let store = Store::open_in_memory(Clock::Fixed(fixtures::now())).unwrap();
+ store
+ .import(|tx| {
+ tx.account(&Account {
+ slug: "household".into(),
+ name: "Household".into(),
+ address: "household@example.com".into(),
+ read_only: false,
+ })
+ })
+ .unwrap();
+
+ exec(
+ &store,
+ "CREATE TRIGGER no_threads BEFORE INSERT ON threads BEGIN SELECT RAISE(ABORT, 'no'); END;",
+ );
+ assert!(matches!(
+ store.import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "x"))),
+ Err(Error::Db(_))
+ ));
+ exec(&store, "DROP TRIGGER no_threads;");
+
+ exec(
+ &store,
+ "CREATE TRIGGER no_messages BEFORE INSERT ON messages BEGIN SELECT RAISE(ABORT, 'no'); END;",
+ );
+ assert!(matches!(
+ store.import(|tx| tx.incoming("household", &incoming(State::Inbox, None, "x"))),
+ Err(Error::Db(_))
+ ));
+ exec(&store, "DROP TRIGGER no_messages;");
+ }
+
#[test]
fn thread_ids_are_scoped_to_their_account() {
// JMAP thread ids are opaque; two accounts may share one without
@@ -1778,13 +1894,15 @@ mod tests {
.unwrap()
.first()
.unwrap()
- .thread;
+ .thread
+ .clone();
let b = store
.messages(Filter::Search("his"))
.unwrap()
.first()
.unwrap()
- .thread;
+ .thread
+ .clone();
assert_ne!(a, b);
}
@@ -1813,12 +1931,23 @@ mod tests {
.unwrap();
let store = Store::init(conn, Clock::Fixed(fixtures::now())).unwrap();
- let msg = store.message(4).unwrap().unwrap();
+ // The mail survived, its ids remade: found by Message-ID now, and
+ // the new id is jj-style reverse hex — 16 characters, no digits,
+ // no order.
+ let msg = store
+ .messages(Filter::Search("Revised estimate"))
+ .unwrap()
+ .first()
+ .cloned()
+ .unwrap();
+ assert_eq!(msg.message_id, "m4@northwind.co");
+ assert_eq!(msg.id.len(), 16);
+ assert!(msg.id.chars().all(|c| ('k'..='z').contains(&c)));
assert_eq!(
msg.values().unwrap().assignees,
BTreeSet::from([ALEX.to_owned()])
);
- assert!(!store.unread(ALEX).unwrap().contains(&4));
+ assert!(!store.unread(ALEX).unwrap().contains(&msg.id));
// The relaxed schema now takes shared-identity sends.
store
@@ -1882,13 +2011,13 @@ mod tests {
#[test]
fn importing_into_a_missing_thread_fails() {
let store = fixtures::store().unwrap();
- let mut m = store.message(4).unwrap().unwrap();
- m.id = 100;
+ let mut m = store.message(&fixtures::id(4)).unwrap().unwrap();
+ m.id = fixtures::id(100);
m.message_id = "new@example.com".into();
- m.thread = 999;
+ m.thread = fixtures::id(999);
let result = store.import(|tx| tx.message(&m));
assert!(matches!(result, Err(Error::NotFound("thread"))));
- assert!(store.message(100).unwrap().is_none());
+ assert!(store.message(&fixtures::id(100)).unwrap().is_none());
}
#[test]
@@ -1900,25 +2029,36 @@ mod tests {
assert!(store.is_empty().unwrap());
fixtures::seed(&store).unwrap();
assert!(!store.is_empty().unwrap());
- store.edit(SAM, 4, Change::State(State::Wait)).unwrap();
store
- .edit(SAM, 4, Change::ToggleAssignee(ALEX.into()))
+ .edit(SAM, &fixtures::id(4), Change::State(State::Wait))
+ .unwrap();
+ store
+ .edit(SAM, &fixtures::id(4), Change::ToggleAssignee(ALEX.into()))
+ .unwrap();
+ store
+ .add_comment(SAM, &fixtures::id(1), "Called them.")
+ .unwrap();
+ store
+ .mark_read(SAM, &fixtures::id(fixtures::WATER))
.unwrap();
- store.add_comment(SAM, 1, "Called them.").unwrap();
- store.mark_read(SAM, fixtures::WATER).unwrap();
drop(store);
let store = Store::open(&path, Clock::Fixed(fixtures::now())).unwrap();
assert!(!store.is_empty().unwrap());
- let v = values(&store, 4);
+ let v = values(&store, &fixtures::id(4));
assert_eq!(v.state, State::Wait);
assert_eq!(v.assignees, BTreeSet::from([ALEX.to_owned()]));
assert!(matches!(
- store.timeline(1).unwrap().last(),
+ store.timeline(&fixtures::id(1)).unwrap().last(),
Some(Item::Comment(c)) if c.text == "Called them."
));
- assert!(!store.unread(SAM).unwrap().contains(&fixtures::WATER));
- assert_eq!(store.history(4).unwrap().len(), 2);
+ assert!(
+ !store
+ .unread(SAM)
+ .unwrap()
+ .contains(&fixtures::id(fixtures::WATER))
+ );
+ assert_eq!(store.history(&fixtures::id(4)).unwrap().len(), 2);
// Undo and toasts belong to the session that made the edit.
assert!(store.take_flash(SAM).is_none());
assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
@@ -1929,25 +2069,35 @@ mod tests {
let store = fixtures::store().unwrap();
// The schema refuses a received message without a state, and a
// folder that doesn't exist.
+ let id = fixtures::id(4);
let inner = store.lock();
assert!(
inner
.conn
- .execute("UPDATE messages SET state = NULL WHERE id = 4", [])
+ .execute(
+ &format!("UPDATE messages SET state = NULL WHERE id = '{id}'"),
+ []
+ )
.is_err()
);
assert!(
inner
.conn
- .execute("UPDATE messages SET folder = 'Nope' WHERE id = 4", [])
+ .execute(
+ &format!("UPDATE messages SET folder = 'Nope' WHERE id = '{id}'"),
+ []
+ )
.is_err()
);
inner
.conn
- .execute("UPDATE messages SET cc = 'not json' WHERE id = 4", [])
+ .execute(
+ &format!("UPDATE messages SET cc = 'not json' WHERE id = '{id}'"),
+ [],
+ )
.unwrap();
drop(inner);
- assert!(matches!(store.message(4), Err(Error::Db(_))));
+ assert!(matches!(store.message(&id), Err(Error::Db(_))));
}
fn exec(store: &Store, sql: &str) {
@@ -1964,58 +2114,60 @@ mod tests {
);
// The state update goes in before the history row that fails.
assert!(matches!(
- store.edit(SAM, 4, Change::State(State::Do)),
+ store.edit(SAM, &fixtures::id(4), Change::State(State::Do)),
Err(Error::Db(_))
));
assert!(matches!(
- store.edit(SAM, 4, Change::ToggleAssignee(ALEX.into())),
+ store.edit(SAM, &fixtures::id(4), Change::ToggleAssignee(ALEX.into())),
Err(Error::Db(_))
));
- assert_eq!(values(&store, 4).state, State::Inbox);
- assert!(values(&store, 4).assignees.is_empty());
- assert!(store.history(4).unwrap().is_empty());
+ assert_eq!(values(&store, &fixtures::id(4)).state, State::Inbox);
+ assert!(values(&store, &fixtures::id(4)).assignees.is_empty());
+ assert!(store.history(&fixtures::id(4)).unwrap().is_empty());
assert!(store.take_flash(SAM).is_none());
// A failed undo stays undoable.
exec(&store, "DROP TRIGGER no_history;");
- store.edit(SAM, 4, Change::State(State::Do)).unwrap();
+ store
+ .edit(SAM, &fixtures::id(4), Change::State(State::Do))
+ .unwrap();
exec(
&store,
"CREATE TRIGGER no_history BEFORE INSERT ON history BEGIN SELECT RAISE(ABORT, 'no'); END;",
);
assert!(matches!(store.undo(SAM), Err(Error::Db(_))));
- assert_eq!(values(&store, 4).state, State::Do);
+ assert_eq!(values(&store, &fixtures::id(4)).state, State::Do);
exec(&store, "DROP TRIGGER no_history;");
store.undo(SAM).unwrap();
- assert_eq!(values(&store, 4).state, State::Inbox);
+ assert_eq!(values(&store, &fixtures::id(4)).state, State::Inbox);
}
#[test]
fn database_errors_surface() {
let store = fixtures::store().unwrap();
- let msg = store.message(4).unwrap().unwrap();
+ let msg = store.message(&fixtures::id(4)).unwrap().unwrap();
exec(&store, "PRAGMA query_only = ON;");
let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
- db_err(store.edit(SAM, 4, Change::State(State::Do)));
- db_err(store.mark_read(SAM, 4));
+ db_err(store.edit(SAM, &fixtures::id(4), Change::State(State::Do)));
+ 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, 1, "hi"));
+ db_err(store.add_comment(SAM, &fixtures::id(1), "hi"));
db_err(store.import(|tx| tx.folder("Travel")));
db_err(store.import(|tx| tx.message(&msg)));
exec(&store, "PRAGMA query_only = OFF;");
// Each table the store reads, gone in turn.
exec(&store, "ALTER TABLE folders RENAME TO gone_folders;");
- db_err(store.edit(SAM, 4, Change::Folder(Some("House".into()))));
+ db_err(store.edit(SAM, &fixtures::id(4), Change::Folder(Some("House".into()))));
assert!(store.folders().is_err());
exec(&store, "ALTER TABLE comments RENAME TO gone_comments;");
- assert!(store.timeline(1).is_err());
+ assert!(store.timeline(&fixtures::id(1)).is_err());
exec(&store, "ALTER TABLE reads RENAME TO gone_reads;");
assert!(store.unread(SAM).is_err());
exec(&store, "ALTER TABLE history RENAME TO gone_history;");
- assert!(store.history(4).is_err());
+ assert!(store.history(&fixtures::id(4)).is_err());
exec(&store, "ALTER TABLE threads RENAME TO gone_threads;");
- db_err(store.add_comment(SAM, 1, "hi"));
+ db_err(store.add_comment(SAM, &fixtures::id(1), "hi"));
exec(&store, "ALTER TABLE users RENAME TO gone_users;");
assert!(store.users().is_err());
assert!(store.is_empty().is_err());
diff --git a/src/views.rs b/src/views.rs
index 3c0f03d..a2dc084 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -50,7 +50,7 @@ pub fn view_path(view: &View) -> String {
}
}
-fn select_path(view: &View, id: u32) -> String {
+fn select_path(view: &View, id: &str) -> String {
let path = view_path(view);
let sep = if path.contains('?') { '&' } else { '?' };
format!("{path}{sep}m={id}")
@@ -247,10 +247,10 @@ fn thread_group(p: &Page<'_>, group: &Group, rows: &[Message], compact: bool) ->
/// A thread with one message listed collapses into a single row: subject and
/// time, then sender and snippet.
fn solo(p: &Page<'_>, group: &Group, m: &Message, compact: bool) -> Markup {
- let selected = p.selected == Some(m.id);
+ let selected = p.selected.as_deref() == Some(m.id.as_str());
let unread = p.unread.contains(&m.id);
html! {
- a.row.solo.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
+ a.row.solo.sel[selected].unread[unread] href=(select_path(p.view, &m.id)) {
span.subj { (group.thread.subject) }
span.type-figure.age { (dates::short(p.now, m.at)) }
span.line {
@@ -276,10 +276,10 @@ fn sender(users: &[User], m: &Message) -> String {
}
fn row(p: &Page<'_>, m: &Message, compact: bool) -> Markup {
- let selected = p.selected == Some(m.id);
+ let selected = p.selected.as_deref() == Some(m.id.as_str());
let unread = p.unread.contains(&m.id);
html! {
- a.row.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
+ a.row.sel[selected].unread[unread] href=(select_path(p.view, &m.id)) {
span.from { (sender(&p.users, m)) }
span.type-figure.age { (dates::short(p.now, m.at)) }
@if !compact {
@@ -292,7 +292,7 @@ fn row(p: &Page<'_>, m: &Message, compact: bool) -> Markup {
fn reader(p: &Page<'_>) -> Markup {
html! {
main.reader {
- @if let (Some(selected), Some(t)) = (p.selected, &p.thread) {
+ @if let (Some(selected), Some(t)) = (p.selected.as_deref(), &p.thread) {
(thread(p, t, selected))
} @else {
p.empty { "No thread open." }
@@ -301,7 +301,7 @@ fn reader(p: &Page<'_>) -> Markup {
}
}
-fn thread(p: &Page<'_>, t: &ThreadView, selected: MessageId) -> Markup {
+fn thread(p: &Page<'_>, t: &ThreadView, selected: &str) -> Markup {
html! {
article.thread {
header.thread-head {
@@ -384,7 +384,7 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
title="Open in Fastmail" aria-label="Open in Fastmail" { (PreEscaped(EXTERNAL_ICON)) }
}
@match m.values() {
- Some(values) => (values_controls(p, m.id, values, account.read_only)),
+ Some(values) => (values_controls(p, &m.id, values, account.read_only)),
None => span.type-label.sent { "Sent" },
}
}
@@ -411,7 +411,7 @@ fn check(on: bool) -> Markup {
html! { span.check { @if on { "✓" } } }
}
-fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> Markup {
+fn values_controls(p: &Page<'_>, id: &str, values: &Values, read_only: bool) -> Markup {
let states = html! {
@for state in State::ALL {
@let on = state == values.state;
@@ -491,20 +491,27 @@ mod tests {
#[test]
fn paths() {
+ let id = crate::fixtures::id(4);
assert_eq!(view_path(&View::ForMe), "/");
assert_eq!(view_path(&View::Lane(State::Wait)), "/wait");
assert_eq!(view_path(&View::Search("a&b".into())), "/search?q=a%26b");
- assert_eq!(select_path(&View::ForMe, 4), "/?m=4");
- assert_eq!(select_path(&View::Search("x".into()), 4), "/search?q=x&m=4");
+ assert_eq!(select_path(&View::ForMe, &id), format!("/?m={id}"));
+ assert_eq!(
+ select_path(&View::Search("x".into()), &id),
+ format!("/search?q=x&m={id}")
+ );
}
#[test]
fn fastmail_links_search_by_message_id() {
let store = crate::fixtures::store().unwrap();
- let m = store.message(4).unwrap().unwrap();
+ let m = store.message(&crate::fixtures::id(4)).unwrap().unwrap();
assert_eq!(
fastmail_url(&m),
- "https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
+ format!(
+ "https://app.fastmail.com/mail/search:msgid%3A%3C{}%40fixtures.docket.invalid%3E",
+ crate::fixtures::id(4)
+ )
);
}
@@ -512,9 +519,9 @@ mod tests {
fn senders_without_a_login_show_the_recipient() {
let users = [User::new("sam@example.com", "Sam")];
let sent = |by: Option<&str>| Message {
- id: 1,
+ id: crate::fixtures::id(1),
message_id: "m@x".into(),
- thread: 1,
+ thread: crate::fixtures::id(1),
at: crate::fixtures::now(),
cc: Vec::new(),
bcc: Vec::new(),
diff --git a/tests/api.rs b/tests/api.rs
index c859751..b7f5523 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -1,6 +1,7 @@
use std::net::SocketAddr;
use axum::Router;
+use docket::fixtures;
use docket::routes::{AppState, router};
use reqwest::{Client, StatusCode, redirect};
use tokio::net::TcpListener;
@@ -39,6 +40,10 @@ fn slug(login: &str) -> String {
.unwrap_or_default()
}
+fn mid(n: u32) -> String {
+ fixtures::id(n)
+}
+
async fn get(addr: SocketAddr, login: &str, path: &str) -> (StatusCode, String) {
let res = client()
.get(format!("http://{addr}{path}"))
@@ -100,7 +105,7 @@ async fn requires_both_identity_headers() {
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{login:?} {slug:?}");
}
// Nobody got added along the way.
- let (_, body) = get(addr, ALEX, "/?m=4").await;
+ let (_, body) = get(addr, ALEX, &format!("/?m={}", mid(4))).await;
assert!(!body.contains(EVE));
}
@@ -113,7 +118,7 @@ async fn anyone_tailscale_lets_in_is_a_user() {
// The name follows the latest X-User-Slug.
let body = client()
- .get(format!("http://{addr}/?m=4"))
+ .get(format!("http://{addr}/?m={}", mid(4)))
.header("Remote-User", "eve@example.com")
.header("X-User-Slug", "Evie")
.send()
@@ -166,7 +171,7 @@ async fn lanes_and_search() {
assert!(body.contains("Nothing here."));
// A search result row links back into the search.
let (_, body) = get(addr, SAM, "/search?q=checkup").await;
- assert!(body.contains("/search?q=checkup&m=15"));
+ assert!(body.contains(&format!("/search?q=checkup&m={}", mid(15))));
for path in ["/done", "/nope", "/folders/Medical", "/?m=999"] {
let (status, _) = get(addr, SAM, path).await;
@@ -177,34 +182,35 @@ async fn lanes_and_search() {
#[tokio::test]
async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
let addr = spawn().await;
- let (status, body) = get(addr, SAM, "/?m=4").await;
+ let (status, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<h2>Gutter repair estimate</h2>"));
// Only the selected email floats.
- assert!(body.contains(r#"class="item card sel" id="m4""#));
- assert!(body.contains(r#"class="item card" id="m3""#));
+ assert!(body.contains(&format!(r#"class="item card sel" id="m{}""#, mid(4))));
+ assert!(body.contains(&format!(r#"class="item card" id="m{}""#, mid(3))));
assert!(body.contains(r#"<span class="type-label sent">Sent</span>"#));
assert!(body.contains(r#"<span class="badge accent"><span class="mark"></span>Do</span>"#));
assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
- assert!(body.contains(
- "https://app.fastmail.com/mail/search:msgid%3A%3C4%40fixtures.docket.invalid%3E"
- ));
+ assert!(body.contains(&format!(
+ "https://app.fastmail.com/mail/search:msgid%3A%3C{}%40fixtures.docket.invalid%3E",
+ mid(4)
+ )));
assert!(body.contains(r#"<button class="btn primary" type="submit">Comment</button>"#));
assert!(body.contains("cc Alex · bcc Pat Lee"));
assert!(body.contains("cc Sam, Alex"));
assert!(body.contains("→ Northwind Roofing"));
assert!(body.contains("The Hendersons paid about $1,600"));
- assert!(body.contains(r#"action="/messages/4/state""#));
- assert!(body.contains(r#"action="/messages/4/folder""#));
- assert!(body.contains(r#"action="/messages/4/assignees""#));
+ assert!(body.contains(&format!(r#"action="/messages/{}/state""#, mid(4))));
+ assert!(body.contains(&format!(r#"action="/messages/{}/folder""#, mid(4))));
+ assert!(body.contains(&format!(r#"action="/messages/{}/assignees""#, mid(4))));
assert!(body.contains(r#"<span class="type-label value unset">Assign</span>"#));
assert!(body.contains(r#"<span class="type-label value">House</span>"#));
- assert!(body.contains(r#"value="/?m=4""#));
+ assert!(body.contains(&format!(r#"value="/?m={}""#, mid(4))));
- let (_, body) = get(addr, ALEX, "/?m=6").await;
+ let (_, body) = get(addr, ALEX, &format!("/?m={}", mid(6))).await;
assert!(body.contains(r#"<span class="type-label value">→ ALEX</span>"#));
assert!(!body.contains(r#"<span class="type-label value">—</span>"#));
- let (_, body) = get(addr, SAM, "/inbox?m=8").await;
+ let (_, body) = get(addr, SAM, &format!("/inbox?m={}", mid(8))).await;
assert!(body.contains(r#"<span class="type-label value">—</span>"#));
assert!(!body.contains("Read-only"));
}
@@ -212,15 +218,15 @@ async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
#[tokio::test]
async fn read_only_accounts_show_folder_as_plain_text() {
let addr = spawn().await;
- let (_, body) = get(addr, SAM, "/watch?m=20").await;
+ let (_, body) = get(addr, SAM, &format!("/watch?m={}", mid(20))).await;
assert!(body.contains(r#"<span class="type-label">Read-only</span>"#));
assert!(body.contains(r#"<span class="type-label value static">—</span>"#));
- assert!(!body.contains(r#"action="/messages/20/folder""#));
- assert!(body.contains(r#"action="/messages/20/state""#));
+ assert!(!body.contains(&format!(r#"action="/messages/{}/folder""#, mid(20))));
+ assert!(body.contains(&format!(r#"action="/messages/{}/state""#, mid(20))));
let res = post(
addr,
SAM,
- "/messages/20/folder",
+ &format!("/messages/{}/folder", mid(20)),
&[("folder", "School"), ("back", "/")],
)
.await;
@@ -231,13 +237,13 @@ async fn read_only_accounts_show_folder_as_plain_text() {
async fn opening_a_message_marks_it_read() {
let addr = spawn().await;
let (_, body) = get(addr, ALEX, "/").await;
- assert!(body.contains(r#"class="row solo unread" href="/?m=7""#));
- get(addr, ALEX, "/?m=7").await;
+ assert!(body.contains(&format!(r#"class="row solo unread" href="/?m={}""#, mid(7))));
+ get(addr, ALEX, &format!("/?m={}", mid(7))).await;
let (_, body) = get(addr, ALEX, "/").await;
- assert!(body.contains(r#"class="row solo" href="/?m=7""#));
+ assert!(body.contains(&format!(r#"class="row solo" href="/?m={}""#, mid(7))));
// Read tracking is per person.
let (_, body) = get(addr, SAM, "/").await;
- assert!(body.contains(r#"class="row solo unread" href="/?m=7""#));
+ assert!(body.contains(&format!(r#"class="row solo unread" href="/?m={}""#, mid(7))));
}
#[tokio::test]
@@ -246,23 +252,23 @@ async fn editing_values_with_undo() {
let res = post(
addr,
SAM,
- "/messages/4/state",
- &[("state", "do"), ("back", "/?m=4")],
+ &format!("/messages/{}/state", mid(4)),
+ &[("state", "do"), ("back", &format!("/?m={}", mid(4)))],
)
.await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
- assert_eq!(location(&res), "/?m=4");
+ assert_eq!(location(&res), &format!("/?m={}", mid(4)));
- let (_, body) = get(addr, SAM, "/?m=4").await;
+ let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert!(body.contains("Moved to Do"));
assert!(body.contains(r#"action="/undo""#));
// The toast shows once.
- let (_, body) = get(addr, SAM, "/?m=4").await;
+ let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert!(!body.contains("Moved to Do"));
- let res = post(addr, SAM, "/undo", &[("back", "/?m=4")]).await;
+ let res = post(addr, SAM, "/undo", &[("back", &format!("/?m={}", mid(4)))]).await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
- let (_, body) = get(addr, SAM, "/?m=4").await;
+ let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert!(body.contains("Undone"));
assert!(!body.contains(r#"action="/undo""#));
let res = post(addr, SAM, "/undo", &[("back", "/")]).await;
@@ -271,7 +277,7 @@ async fn editing_values_with_undo() {
let res = post(
addr,
SAM,
- "/messages/4/folder",
+ &format!("/messages/{}/folder", mid(4)),
&[("folder", "Finance"), ("back", "/")],
)
.await;
@@ -279,32 +285,32 @@ async fn editing_values_with_undo() {
let res = post(
addr,
SAM,
- "/messages/4/folder",
+ &format!("/messages/{}/folder", mid(4)),
&[("folder", ""), ("back", "/")],
)
.await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
- let (_, body) = get(addr, SAM, "/?m=4").await;
+ let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert!(body.contains("Removed from folder"));
let res = post(
addr,
SAM,
- "/messages/4/assignees",
+ &format!("/messages/{}/assignees", mid(4)),
&[("user", ALEX), ("back", "/")],
)
.await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
// Now it's Alex's; Sam's For me no longer lists it, Alex's does.
let (_, body) = get(addr, ALEX, "/").await;
- assert!(body.contains(r#"href="/?m=4""#));
+ assert!(body.contains(&format!(r#"href="/?m={}""#, mid(4))));
let (_, body) = get(addr, SAM, "/").await;
- assert!(!body.contains(r#"href="/?m=4""#));
+ assert!(!body.contains(&format!(r#"href="/?m={}""#, mid(4))));
let res = post(
addr,
SAM,
- "/messages/4/state",
+ &format!("/messages/{}/state", mid(4)),
&[("state", "read"), ("back", "/")],
)
.await;
@@ -312,7 +318,7 @@ async fn editing_values_with_undo() {
let res = post(
addr,
SAM,
- "/messages/2/state",
+ &format!("/messages/{}/state", mid(2)),
&[("state", "do"), ("back", "/")],
)
.await;
@@ -320,7 +326,7 @@ async fn editing_values_with_undo() {
let res = post(
addr,
SAM,
- "/messages/4/state",
+ &format!("/messages/{}/state", mid(4)),
&[("state", "do"), ("back", "https://evil.example")],
)
.await;
@@ -333,17 +339,20 @@ async fn comments_join_the_thread() {
let res = post(
addr,
ALEX,
- "/threads/1/comments",
- &[("text", "I'll call them."), ("back", "/?m=4")],
+ &format!("/threads/{}/comments", mid(1)),
+ &[
+ ("text", "I'll call them."),
+ ("back", &format!("/?m={}", mid(4))),
+ ],
)
.await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
- let (_, body) = get(addr, SAM, "/?m=4").await;
+ let (_, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert!(body.contains("I'll call them.") || body.contains("I'll call them."));
let res = post(
addr,
ALEX,
- "/threads/1/comments",
+ &format!("/threads/{}/comments", mid(1)),
&[("text", " "), ("back", "/")],
)
.await;
@@ -355,7 +364,7 @@ async fn cross_site_posts_are_refused() {
let addr = spawn().await;
let send = |origin: &'static str| {
client()
- .post(format!("http://{addr}/messages/4/state"))
+ .post(format!("http://{addr}/messages/{}/state", mid(4)))
.header("Remote-User", SAM)
.header("X-User-Slug", "Sam")
.header("Origin", origin)
@@ -485,21 +494,33 @@ async fn trashing_confirms_through_a_dialog_and_marks_done() {
// The trash affordance is a dialog on writable accounts: open it
// with showModal, cancel closes without posting, and the form
// targets the delete route.
- let (_, body) = get(addr, SAM, "/inbox?m=8").await;
- assert!(body.contains(r#"id="trash-8""#));
+ let (_, body) = get(addr, SAM, &format!("/inbox?m={}", mid(8))).await;
+ assert!(body.contains(&format!(r#"id="trash-{}""#, mid(8))));
assert!(body.contains("showModal"));
assert!(body.contains("Move to Trash?"));
assert!(body.contains(r#"formmethod="dialog""#));
- assert!(body.contains(r#"action="/messages/8/delete""#));
+ assert!(body.contains(&format!(r#"action="/messages/{}/delete""#, mid(8))));
- let res = post(addr, SAM, "/messages/8/delete", &[("back", "/inbox?m=8")]).await;
+ let res = post(
+ addr,
+ SAM,
+ &format!("/messages/{}/delete", mid(8)),
+ &[("back", &format!("/inbox?m={}", mid(8)))],
+ )
+ .await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
- let (_, body) = get(addr, SAM, "/search?q=142.18&m=8").await;
+ let (_, body) = get(addr, SAM, &format!("/search?q=142.18&m={}", mid(8))).await;
assert!(body.contains(r#"<span class="badge success"><span class="mark"></span>Done</span>"#));
// Read-only accounts get neither the dialog nor the route.
- let (_, body) = get(addr, SAM, "/watch?m=20").await;
+ let (_, body) = get(addr, SAM, &format!("/watch?m={}", mid(20))).await;
assert!(!body.contains(r#"id="trash-20""#));
- let res = post(addr, SAM, "/messages/20/delete", &[("back", "/watch?m=20")]).await;
+ let res = post(
+ addr,
+ SAM,
+ &format!("/messages/{}/delete", mid(20)),
+ &[("back", &format!("/watch?m={}", mid(20)))],
+ )
+ .await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 9b9994e..0452ef6 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -784,11 +784,11 @@ async fn each_credential_opens_a_session_and_imports_its_mail() {
// Threads group their messages under one subject.
assert_eq!(
- store.thread(roofer1.thread).unwrap().unwrap().subject,
+ store.thread(&roofer1.thread).unwrap().unwrap().subject,
"Gutter repair estimate"
);
assert_eq!(
- store.thread(school.thread).unwrap().unwrap().subject,
+ store.thread(&school.thread).unwrap().unwrap().subject,
"Field trip"
);
assert_ne!(roofer1.thread, school.thread);
@@ -1194,7 +1194,7 @@ async fn filing_pushes_through_the_poll() {
store
.edit(
"sam@example.com",
- school.id,
+ &school.id,
Change::Folder(Some("Receipts".into())),
)
.unwrap();
@@ -1226,13 +1226,13 @@ async fn filing_pushes_through_the_poll() {
// The push cleared the queue, and the same cycle's /changes sweep
// confirmed the filing in the cached row.
assert!(store.pending_files("household").unwrap().is_empty());
- let after = store.message(school.id).unwrap().unwrap();
+ let after = store.message(&school.id).unwrap().unwrap();
assert_eq!(after.values().unwrap().folder.as_deref(), Some("Receipts"));
assert_eq!(after.values().unwrap().state, State::Watch);
// Unfiling queues again and empties every folder membership.
store
- .edit("sam@example.com", school.id, Change::Folder(None))
+ .edit("sam@example.com", &school.id, Change::Folder(None))
.unwrap();
let counts = client
.poll_once(&household, &mut sync, &store)
@@ -1266,7 +1266,7 @@ async fn acl_denied_filings_wait_for_the_rights() {
store
.edit(
"sam@example.com",
- school.id,
+ &school.id,
Change::Folder(Some("Receipts".into())),
)
.unwrap();
@@ -1300,7 +1300,7 @@ async fn a_refused_filing_clears_rather_than_retries() {
store
.edit(
"sam@example.com",
- school.id,
+ &school.id,
Change::Folder(Some("Receipts".into())),
)
.unwrap();
@@ -1317,7 +1317,7 @@ async fn a_refused_filing_clears_rather_than_retries() {
assert!(store.pending_files("household").unwrap().is_empty());
assert_eq!(
store
- .message(school.id)
+ .message(&school.id)
.unwrap()
.unwrap()
.values()
@@ -1334,7 +1334,7 @@ async fn a_filing_for_mail_thats_gone_drops() {
store
.edit(
"sam@example.com",
- school.id,
+ &school.id,
Change::Folder(Some("Receipts".into())),
)
.unwrap();
@@ -1356,7 +1356,7 @@ async fn a_filing_into_a_vanished_folder_drops() {
store
.edit(
"sam@example.com",
- school.id,
+ &school.id,
Change::Folder(Some("Receipts".into())),
)
.unwrap();
@@ -1381,7 +1381,7 @@ async fn done_archives_out_of_the_shared_inbox() {
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::Done))
+ .edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
assert_eq!(store.pending_archives("household").unwrap().len(), 1);
@@ -1410,7 +1410,7 @@ async fn done_archives_out_of_the_shared_inbox() {
assert!(store.pending_archives("household").unwrap().is_empty());
assert_eq!(
store
- .message(school.id)
+ .message(&school.id)
.unwrap()
.unwrap()
.values()
@@ -1433,7 +1433,7 @@ async fn mail_already_out_of_the_inbox_exits_nowhere() {
});
let school = school_message(&store);
store
- .edit("sam@example.com", school.id, Change::State(State::Done))
+ .edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
let counts = client
@@ -1467,7 +1467,7 @@ async fn acl_denied_archives_wait_for_the_rights() {
});
let school = school_message(&store);
store
- .edit("sam@example.com", school.id, Change::State(State::Done))
+ .edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
let counts = client
@@ -1497,7 +1497,7 @@ async fn a_refused_archive_clears_rather_than_retries() {
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::Done))
+ .edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
world.lock().unwrap().refuse.insert("E-school".into());
@@ -1515,7 +1515,7 @@ async fn an_archive_for_mail_thats_gone_drops() {
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::Done))
+ .edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
world.lock().unwrap().remove_email(HOUSEHOLD, "E-school");
@@ -1533,7 +1533,7 @@ async fn an_archive_with_nowhere_to_land_drops() {
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::Done))
+ .edit("sam@example.com", &school.id, Change::State(State::Done))
.unwrap();
// The server loses its Archive mailbox before the exit lands.
world
@@ -1567,7 +1567,7 @@ async fn an_archive_with_nowhere_to_land_drops() {
async fn trashing_destroys_through_the_poll() {
let (world, client, household, mut sync, store, _dir) = household_ready().await;
let school = school_message(&store);
- store.delete("sam@example.com", school.id).unwrap();
+ store.delete("sam@example.com", &school.id).unwrap();
assert_eq!(store.pending_deletes("household").unwrap().len(), 1);
let counts = client
@@ -1588,7 +1588,7 @@ async fn trashing_destroys_through_the_poll() {
// The cached row stays, Done.
assert_eq!(
store
- .message(school.id)
+ .message(&school.id)
.unwrap()
.unwrap()
.values()
@@ -1602,7 +1602,7 @@ async fn trashing_destroys_through_the_poll() {
async fn a_refused_destruction_clears_rather_than_retries() {
let (world, client, household, mut sync, store, _dir) = household_ready().await;
let school = school_message(&store);
- store.delete("sam@example.com", school.id).unwrap();
+ store.delete("sam@example.com", &school.id).unwrap();
world.lock().unwrap().refuse.insert("E-school".into());
let counts = client
@@ -1618,7 +1618,7 @@ async fn a_refused_destruction_clears_rather_than_retries() {
async fn a_destruction_for_mail_thats_gone_clears() {
let (world, client, household, mut sync, store, _dir) = household_ready().await;
let school = school_message(&store);
- store.delete("sam@example.com", school.id).unwrap();
+ store.delete("sam@example.com", &school.id).unwrap();
world.lock().unwrap().remove_email(HOUSEHOLD, "E-school");
let counts = client