From 37a5e0d498dd0987de80c7e5962b359204028a3c Mon Sep 17 00:00:00 2001 From: yeroo Date: Thu, 5 Nov 2026 12:00:58 +0400 Subject: [PATCH 1/4] mailcore: record the format a body was fetched as (#1901) An Html engine gets each body native, so a plain-text message comes back as text: content_type alone cannot tell "asked for html, got text" from "fetched as text by an older engine". bodies.fetched_as records what was asked (put_fetched_body), NULL for local writes and pre-column rows, and get_body_for(id, format) answers whether the stored body satisfies a format, so an embedder refetches each message once, not on every open. update_draft_fields now also makes the body html with no fetch format: compose writes html, and a draft fetched as text kept content_type text over html content, which would have invited a refetch over the edits. Co-Authored-By: Claude Opus 5.5 --- mailcore/src/graph/model.rs | 33 ++++ mailcore/src/store/mod.rs | 299 +++++++++++++++++++++++++++++++++-- mailcore/src/store/schema.rs | 7 +- 3 files changed, 329 insertions(+), 10 deletions(-) diff --git a/mailcore/src/graph/model.rs b/mailcore/src/graph/model.rs index 0c687951..19f7d457 100644 --- a/mailcore/src/graph/model.rs +++ b/mailcore/src/graph/model.rs @@ -246,6 +246,39 @@ impl Body { } } +/// Which body format the sync engine asks Graph for on a `FetchBody` +/// (#1901). `Text` sends `Prefer: outlook.body-content-type="text"`, so +/// every body comes back as plain text (lookxy, a terminal client). `Html` +/// sends no `Prefer`, so each body comes back in its *native* format: html +/// for an html message, but still text for a plain-text one. That is why +/// the store records the format a body was asked for (`bodies.fetched_as`), +/// not just the content type it came back as. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BodyFormat { + #[default] + Text, + Html, +} + +impl BodyFormat { + /// The value stored in `bodies.fetched_as`. + pub fn as_str(self) -> &'static str { + match self { + BodyFormat::Text => "text", + BodyFormat::Html => "html", + } + } + + /// The inverse of [`BodyFormat::as_str`]; `None` for anything else. + pub fn parse(s: &str) -> Option { + match s { + "text" => Some(BodyFormat::Text), + "html" => Some(BodyFormat::Html), + _ => None, + } + } +} + /// Which Graph attachment kind this is (`@odata.type`). Determines what the /// UI does on save: `File` downloads its `contentBytes`; `Item` downloads its /// `/$value` MIME (`.eml`/`.ics`); `Reference` opens `source_url`. diff --git a/mailcore/src/store/mod.rs b/mailcore/src/store/mod.rs index 4e36ea57..e139e03e 100644 --- a/mailcore/src/store/mod.rs +++ b/mailcore/src/store/mod.rs @@ -12,8 +12,8 @@ use std::path::Path; use rusqlite::{Connection, Row, params}; use crate::graph::model::{ - AttachmentKind, AttachmentMeta, Attendee, Body, Event, MailFolder, MasterCategory, Message, - MessageFields, Recipient, Recurrence, + AttachmentKind, AttachmentMeta, Attendee, Body, BodyFormat, Event, MailFolder, MasterCategory, + Message, MessageFields, Recipient, Recurrence, }; use crate::json::{self, Value}; @@ -629,6 +629,11 @@ impl Store { // come after the ALTER, not in `SCHEMA_SQL`: on an old database // `SCHEMA_SQL` runs before the column exists. NULLs never collide. let _ = conn.execute("ALTER TABLE messages ADD COLUMN local_id TEXT", []); + // Same idempotent-migration pattern, for `bodies.fetched_as` (#1901: + // the format `FetchBody` asked for). Rows from before it stay NULL, + // which `get_body_for` reads as "asked for text" for a text body, + // as the engine then always did. + let _ = conn.execute("ALTER TABLE bodies ADD COLUMN fetched_as TEXT", []); conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(local_id)", [], @@ -1061,20 +1066,103 @@ impl Store { Ok(()) } - /// Inserts or replaces the body of a message. The schema's `bodies_fts_*` - /// triggers (see `schema.rs`) keep `messages_fts` in step with this - /// automatically, so `search` sees the new content right away. + /// Inserts or replaces the body of a message, as a body that was not + /// fetched by `FetchBody` (a draft's, written locally or from + /// `createReply`): no fetch format is recorded (`fetched_as` NULL; see + /// [`Store::get_body_for`]). The schema's `bodies_fts_*` triggers (see + /// `schema.rs`) keep `messages_fts` in step with this automatically, so + /// `search` sees the new content right away. pub fn put_body(&self, message_id: &str, b: &Body) -> Result<(), StoreError> { + self.write_body(message_id, b, None) + } + + /// Inserts or replaces the body of a message as `FetchBody` fetched it, + /// recording the format it asked Graph for (`fetched_as`, #1901), which + /// for `Html` can differ from the content type that came back (a + /// plain-text message's native body is text). + pub fn put_fetched_body( + &self, + message_id: &str, + b: &Body, + asked: BodyFormat, + ) -> Result<(), StoreError> { + self.write_body(message_id, b, Some(asked)) + } + + fn write_body( + &self, + message_id: &str, + b: &Body, + asked: Option, + ) -> Result<(), StoreError> { self.conn.execute( - "INSERT INTO bodies (message_id, content_type, content) VALUES (?1, ?2, ?3) + "INSERT INTO bodies (message_id, content_type, content, fetched_as) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(message_id) DO UPDATE SET content_type = excluded.content_type, - content = excluded.content", - params![message_id, b.content_type, b.content], + content = excluded.content, + fetched_as = excluded.fetched_as", + params![message_id, b.content_type, b.content, asked.map(BodyFormat::as_str)], )?; Ok(()) } + /// The stored body of a message when it satisfies `format`, else `None` + /// (#1901): what an embedder checks before sending `FetchBody`, so a + /// store filled under the other format upgrades one message at a time + /// and a body is fetched once per format, not on every open. Use + /// [`Store::get_body`] to show whatever is stored meanwhile. + /// + /// A body satisfies `format` unless it was asked for in the other one. + /// What it was asked for is `fetched_as` when `FetchBody` recorded it; + /// otherwise (`NULL`) a text body counts as asked for text, since an + /// engine from before the column always asked for text, and an html + /// body (a draft written locally, or by `createReply`) as asked for + /// neither, so it satisfies both. So a plain-text message fetched under + /// `Html` (its native body is text) satisfies `Html` and is not fetched + /// again. One edge: a `createReply` draft of a plain-text message comes + /// back as text and is stored with no format, so under `Html` it reads + /// as asked for text and is fetched once more, natively (the same text); + /// compose edits are stored as html (`update_draft_fields`), so they + /// are never invited to be overwritten. + pub fn get_body_for( + &self, + message_id: &str, + format: BodyFormat, + ) -> Result, StoreError> { + let row = self + .conn + .query_row( + "SELECT content_type, content, fetched_as FROM bodies WHERE message_id = ?1", + params![message_id], + |row| { + Ok(( + Body { + content_type: row.get(0)?, + content: row.get(1)?, + }, + row.get::<_, Option>(2)?, + )) + }, + ) + .map(Some) + .or_else(|e| { + if e == rusqlite::Error::QueryReturnedNoRows { + Ok(None) + } else { + Err(e) + } + })?; + let Some((body, fetched_as)) = row else { + return Ok(None); + }; + let satisfies = match fetched_as.as_deref() { + // A value this build does not know is not `format`: refetch. + Some(asked) => BodyFormat::parse(asked) == Some(format), + None => body.content_type.eq_ignore_ascii_case("html") || format == BodyFormat::Text, + }; + Ok(satisfies.then_some(body)) + } + /// The stored body of a message, if any (`None` before `put_body` is /// ever called for it). pub fn get_body(&self, message_id: &str) -> Result, StoreError> { @@ -1151,6 +1239,11 @@ impl Store { /// matched by `draft_row_sql!` and the body keyed by `current_id_sql!`, /// in one transaction whose first statement is the write, so both see /// the same state. + /// + /// The body is html (compose writes html), so a draft whose stored body + /// was fetched as text becomes `content_type = 'html'` with no fetch + /// format (#1901): [`Store::get_body_for`] then keeps the user's edits + /// in either format rather than inviting a `FetchBody` over them. pub fn update_draft_fields( &self, id: &str, @@ -1174,7 +1267,8 @@ impl Store { "INSERT INTO bodies (message_id, content_type, content) VALUES (", current_id_sql!(), ", 'html', ?2) - ON CONFLICT(message_id) DO UPDATE SET content = excluded.content" + ON CONFLICT(message_id) DO UPDATE SET + content_type = 'html', content = excluded.content, fetched_as = NULL" ), params![id, body_html], )?; @@ -4341,6 +4435,193 @@ mod local_id_tests { assert_eq!(s.draft(&local).unwrap().unwrap().0.subject, "From Graph"); } + // --- Body format (#1901) ------------------------------------------------ + + fn text_body(content: &str) -> Body { + Body { + content_type: "text".into(), + content: content.into(), + } + } + + fn html_body(content: &str) -> Body { + Body { + content_type: "html".into(), + content: content.into(), + } + } + + /// A store holding one message `M` (in Drafts, so `update_draft_fields` + /// reaches it), for bodies to hang off. + fn store_with_message() -> Store { + let s = Store::open_in_memory().unwrap(); + let folder = s.drafts_folder_id().unwrap(); + let m = Message { + id: "M".into(), + conversation_id: String::new(), + subject: "S".into(), + from: Recipient::default(), + to: Vec::new(), + cc: Vec::new(), + received: String::new(), + sent: String::new(), + is_read: true, + is_flagged: false, + has_attachments: false, + importance: "normal".into(), + preview: String::new(), + is_draft: false, + is_meeting_request: false, + categories: Vec::new(), + }; + s.upsert_message(&folder, &m).unwrap(); + s + } + + fn satisfies(s: &Store, id: &str) -> (bool, bool) { + ( + s.get_body_for(id, BodyFormat::Text).unwrap().is_some(), + s.get_body_for(id, BodyFormat::Html).unwrap().is_some(), + ) + } + + #[test] + fn get_body_for_has_nothing_without_a_body() { + let s = store_with_message(); + assert_eq!(satisfies(&s, "M"), (false, false)); + assert_eq!(satisfies(&s, "nope"), (false, false)); + } + + #[test] + fn a_fetched_body_satisfies_the_format_it_was_asked_in() { + let s = store_with_message(); + s.put_fetched_body("M", &text_body("t"), BodyFormat::Text) + .unwrap(); + assert_eq!(satisfies(&s, "M"), (true, false)); + s.put_fetched_body("M", &html_body("

h

"), BodyFormat::Html) + .unwrap(); + assert_eq!(satisfies(&s, "M"), (false, true)); + let b = s.get_body_for("M", BodyFormat::Html).unwrap().unwrap(); + assert_eq!(b, html_body("

h

")); + } + + #[test] + fn a_native_text_body_asked_as_html_satisfies_html() { + // A plain-text message's native body is text: asked for html, it is + // the right answer and must not read as "fetch again". + let s = store_with_message(); + s.put_fetched_body("M", &text_body("plain"), BodyFormat::Html) + .unwrap(); + assert_eq!(satisfies(&s, "M"), (false, true)); + } + + #[test] + fn a_body_with_no_format_counts_as_text_when_text_and_as_either_when_html() { + // No `fetched_as`: an older engine's fetch (always text), or a body + // written locally. `put_body` writes none, and overwrites a format. + let s = store_with_message(); + s.put_body("M", &text_body("old")).unwrap(); + assert_eq!(satisfies(&s, "M"), (true, false)); + s.put_body("M", &html_body("

local

")).unwrap(); + assert_eq!(satisfies(&s, "M"), (true, true)); + s.put_fetched_body("M", &text_body("t"), BodyFormat::Text) + .unwrap(); + s.put_body("M", &html_body("

local

")).unwrap(); + assert_eq!(satisfies(&s, "M"), (true, true)); + // Content types compare as lookxy's readers do, ignoring case. + s.put_body( + "M", + &Body { + content_type: "HTML".into(), + content: "

x

".into(), + }, + ) + .unwrap(); + assert_eq!(satisfies(&s, "M"), (true, true)); + } + + #[test] + fn an_unknown_fetch_format_is_fetched_again() { + let s = store_with_message(); + s.put_fetched_body("M", &html_body("

h

"), BodyFormat::Html) + .unwrap(); + s.conn + .execute("UPDATE bodies SET fetched_as = 'rtf'", []) + .unwrap(); + assert_eq!(satisfies(&s, "M"), (false, false)); + } + + #[test] + fn local_draft_bodies_satisfy_both_formats() { + let s = Store::open_in_memory().unwrap(); + let id = s.create_local_draft("S", "", "", "

hi

").unwrap(); + assert_eq!(satisfies(&s, &id), (true, true)); + s.update_draft_fields(&id, "S", "", "", "", "

edited

") + .unwrap(); + assert_eq!(satisfies(&s, &id), (true, true)); + } + + #[test] + fn editing_a_draft_fetched_as_text_keeps_the_edit_for_html() { + // A synced draft whose body `FetchBody` stored as text: compose + // writes html, which must not read as "fetch the html over it". + let s = store_with_message(); + s.put_fetched_body("M", &text_body("server text"), BodyFormat::Text) + .unwrap(); + s.update_draft_fields("M", "S", "", "", "", "

my edit

") + .unwrap(); + let b = s.get_body_for("M", BodyFormat::Html).unwrap().unwrap(); + assert_eq!(b, html_body("

my edit

")); + assert_eq!(satisfies(&s, "M"), (true, true)); + } + + #[test] + fn reconcile_id_keeps_the_fetch_format() { + let s = store_with_message(); + s.put_fetched_body("M", &html_body("

h

"), BodyFormat::Html) + .unwrap(); + s.reconcile_id("M", "G").unwrap(); + assert_eq!(satisfies(&s, "G"), (false, true)); + } + + #[test] + fn a_database_from_before_fetched_as_is_migrated() { + let dir = std::env::temp_dir().join(format!( + "lookxy-store-fetched-as-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("mail.db"); + let id = { + let s = Store::open(&path).unwrap(); + let id = s.create_local_draft("Old", "", "", "body").unwrap(); + s.conn + .execute( + "UPDATE bodies SET content_type = 'text' WHERE message_id = ?1", + params![id], + ) + .unwrap(); + // Take the database back to before the column existed. + s.conn + .execute_batch("ALTER TABLE bodies DROP COLUMN fetched_as;") + .unwrap(); + id + }; + let s = Store::open(&path).unwrap(); + // The old row reads as an older engine's text fetch. + assert_eq!(satisfies(&s, &id), (true, false)); + s.put_fetched_body(&id, &html_body("

h

"), BodyFormat::Html) + .unwrap(); + assert_eq!(satisfies(&s, &id), (false, true)); + drop(s); + // Opening again (column already there) is fine. + let s = Store::open(&path).unwrap(); + assert_eq!(satisfies(&s, &id), (false, true)); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_database_from_before_local_id_is_migrated() { let dir = std::env::temp_dir().join(format!( diff --git a/mailcore/src/store/schema.rs b/mailcore/src/store/schema.rs index d45e98a5..2cf87ab8 100644 --- a/mailcore/src/store/schema.rs +++ b/mailcore/src/store/schema.rs @@ -69,10 +69,15 @@ CREATE TABLE IF NOT EXISTS contacts ( relevance INTEGER ); +-- `fetched_as` is the format `FetchBody` asked Graph for (`text`/`html`, +-- #1901); NULL for a body written locally (drafts, createReply) or by an +-- engine from before the column (which always asked for text). See +-- `Store::get_body_for`. CREATE TABLE IF NOT EXISTS bodies ( message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, content_type TEXT NOT NULL DEFAULT '', - content TEXT NOT NULL DEFAULT '' + content TEXT NOT NULL DEFAULT '', + fetched_as TEXT ); CREATE TABLE IF NOT EXISTS attachments ( From ec0d83f2443d41b628f98ce7fe5ca28ced58b49e Mon Sep 17 00:00:00 2001 From: yeroo Date: Thu, 5 Nov 2026 12:00:58 +0400 Subject: [PATCH 2/4] mailcore: let the engine fetch HTML bodies (#1901) EngineOptions { body_format } is chosen at spawn (spawn_with_options); spawn keeps Text, so lookxy is unchanged. Html sends no Prefer header and stores each body with the format it asked for. Tests pin the Prefer header per format, the one-time upgrade of a text-stored body, no refetch of a native-text answer, and inline attachments' contentId under Html. Co-Authored-By: Claude Opus 5.5 --- mailcore/src/sync/engine.rs | 322 +++++++++++++++++++++++++++++++++++- 1 file changed, 316 insertions(+), 6 deletions(-) diff --git a/mailcore/src/sync/engine.rs b/mailcore/src/sync/engine.rs index d33868da..66d0e418 100644 --- a/mailcore/src/sync/engine.rs +++ b/mailcore/src/sync/engine.rs @@ -19,6 +19,7 @@ use crate::auth::{self, AuthConfig, TokenSet}; use crate::graph::client::{DeltaCursor, GraphClient, GraphError, RsvpKind}; +pub use crate::graph::model::BodyFormat; use crate::graph::model::{AutomaticReplies, DeltaItem, Message, ScheduleEntry}; use crate::store::{NewAttendee, NewEvent, OutboxOp, Store, StoreError}; use crate::sync::outbox::apply_op; @@ -60,7 +61,8 @@ pub enum SyncCommand { Move { id: String, dest: String }, /// Delete a message (optimistic local delete + queued Graph op). Delete { id: String }, - /// Fetch and store a message body, then emit [`SyncEvent::BodyReady`]. + /// Fetch and store a message body in the engine's + /// [`EngineOptions::body_format`], then emit [`SyncEvent::BodyReady`]. FetchBody { id: String }, /// Fetch attachment metadata (`GraphClient::list_attachments`) for a /// message and store it, then emit [`SyncEvent::AttachmentsUpdated`]. @@ -298,6 +300,9 @@ const SIGNIN_READ_TIMEOUT: Duration = Duration::from_secs(10); /// much of the tick has elapsed) — lookxy passes its `Config::refresh_secs` /// here, so the configured interval genuinely governs how often the engine /// syncs on its own. +/// +/// Runs with [`EngineOptions::default`] (plain-text bodies); see +/// [`spawn_with_options`]. pub fn spawn( store_path: PathBuf, token_path: PathBuf, @@ -305,7 +310,37 @@ pub fn spawn( backfill_days: i64, tick: Duration, ) -> SyncHandle { - spawn_with_bases( + spawn_with_options( + store_path, + token_path, + cfg, + backfill_days, + tick, + EngineOptions::default(), + ) +} + +/// Engine behaviour chosen once, at spawn (#1901). Build it from the +/// default so a later field does not break the caller: +/// `EngineOptions { body_format: BodyFormat::Html, ..Default::default() }`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct EngineOptions { + /// The format [`SyncCommand::FetchBody`] asks Graph for (default + /// `Text`, what lookxy shows). Bodies stored under the other format are + /// told apart with `Store::get_body_for`. + pub body_format: BodyFormat, +} + +/// Like [`spawn`], with the engine's [`EngineOptions`]. +pub fn spawn_with_options( + store_path: PathBuf, + token_path: PathBuf, + cfg: AuthConfig, + backfill_days: i64, + tick: Duration, + options: EngineOptions, +) -> SyncHandle { + spawn_with_bases_and_options( store_path, token_path, cfg, @@ -313,12 +348,14 @@ pub fn spawn( "https://graph.microsoft.com/v1.0".to_string(), "https://login.microsoftonline.com".to_string(), tick, + options, ) } /// Like [`spawn`], but with the Graph base URL, auth base URL, and tick /// injected — the seam integration tests use to point the whole engine at the /// in-process fake server. +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub(crate) fn spawn_with_bases( store_path: PathBuf, @@ -328,6 +365,30 @@ pub(crate) fn spawn_with_bases( graph_base: String, auth_base: String, tick: Duration, +) -> SyncHandle { + spawn_with_bases_and_options( + store_path, + token_path, + cfg, + backfill_days, + graph_base, + auth_base, + tick, + EngineOptions::default(), + ) +} + +/// [`spawn_with_bases`] with the engine's [`EngineOptions`]. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_with_bases_and_options( + store_path: PathBuf, + token_path: PathBuf, + cfg: AuthConfig, + backfill_days: i64, + graph_base: String, + auth_base: String, + tick: Duration, + options: EngineOptions, ) -> SyncHandle { let (cmd_tx, cmd_rx) = mpsc::channel(); let (evt_tx, evt_rx) = mpsc::channel(); @@ -339,6 +400,7 @@ pub(crate) fn spawn_with_bases( graph_base, auth_base, tick, + options, }; thread::spawn(move || run(config, cmd_rx, evt_tx)); SyncHandle { cmd_tx, evt_rx } @@ -353,6 +415,7 @@ struct EngineConfig { graph_base: String, auth_base: String, tick: Duration, + options: EngineOptions, } /// Thread entry point: open the store, build the engine, run startup, then the @@ -1106,16 +1169,23 @@ impl Engine { // --- Bodies ---------------------------------------------------------- - /// Fetch a message body (plain text, best for a TUI), store it, and emit - /// `BodyReady`. + /// Fetch a message body in the engine's `EngineOptions::body_format`, + /// store it, and emit `BodyReady`. `Text` asks Graph for plain text + /// (best for a TUI); `Html` sends no `Prefer` and gets each body in its + /// native format (html, or text for a plain-text message). The body is + /// stored with the format *asked for*, not the content type it came back + /// as (`Store::put_fetched_body`), so `Store::get_body_for` can tell a + /// native-text answer to an `Html` ask from a body to refetch. Every + /// `FetchBody` goes to Graph: the caller decides when one is needed. fn fetch_body(&mut self, id: &str) { if self.token.is_none() { self.emit(SyncEvent::Error("not signed in".to_string())); return; } - match self.with_auth(|c| c.get_body(id, true)) { + let format = self.config.options.body_format; + match self.with_auth(|c| c.get_body(id, format == BodyFormat::Text)) { Ok(body) => { - let _ = self.store.put_body(id, &body); + let _ = self.store.put_fetched_body(id, &body, format); self.emit(SyncEvent::BodyReady { id: id.to_string() }); } Err(e) => { @@ -3875,6 +3945,246 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // --- Body format (#1901) ------------------------------------------------ + + /// `backfill_routes` with `extra` routes ahead of them (the fake server + /// matches the first prefix), an engine spawned with `options` on it, + /// and the backfill landed (so M1 exists locally). + fn body_engine( + tag: &str, + extra: Vec, + options: EngineOptions, + ) -> (FakeServer, SyncHandle, PathBuf, PathBuf) { + let mut routes = extra; + routes.extend(backfill_routes()); + let srv = FakeServer::start(routes); + let base = srv.base_url.clone(); + let dir = unique_dir(tag); + let store_path = dir.join("mail.db"); + let token_path = dir.join("token.bin"); + seed_token(&token_path); + let handle = spawn_with_bases_and_options( + store_path.clone(), + token_path, + test_cfg(), + 3650, + base.clone(), + base, + Duration::from_secs(3600), + options, + ); + wait_for( + &handle.evt_rx, + |e| matches!(e, SyncEvent::MessagesUpdated { folder_id } if folder_id == "F1"), + ); + (srv, handle, store_path, dir) + } + + fn html_options() -> EngineOptions { + EngineOptions { + body_format: BodyFormat::Html, + } + } + + /// M1's body GET (`get_body`'s path), answering `content_type`/`content`. + fn body_route(content_type: &str, content: &str) -> Route { + get_route( + "/me/messages/M1?$select=body", + 200, + &format!(r#"{{"body":{{"contentType":"{content_type}","content":"{content}"}}}}"#), + ) + } + + fn fetch_m1_body(handle: &SyncHandle) { + handle + .cmd_tx + .send(SyncCommand::FetchBody { id: "M1".into() }) + .unwrap(); + wait_for( + &handle.evt_rx, + |e| matches!(e, SyncEvent::BodyReady { id } if id == "M1"), + ); + } + + /// The body GETs the server saw for M1. + fn m1_body_gets(srv: &FakeServer) -> Vec { + srv.requests() + .into_iter() + .filter(|r| r.method == "GET" && r.path.starts_with("/me/messages/M1?$select=body")) + .collect() + } + + fn prefers_text(r: &crate::testserver::RecordedRequest) -> bool { + r.headers.iter().any(|(k, v)| { + k.eq_ignore_ascii_case("prefer") && v == "outlook.body-content-type=\"text\"" + }) + } + + #[test] + fn a_default_engine_fetches_bodies_as_text_and_records_it() { + let (srv, handle, store_path, dir) = body_engine( + "body-text", + vec![body_route("text", "hi")], + EngineOptions::default(), + ); + fetch_m1_body(&handle); + + let gets = m1_body_gets(&srv); + assert_eq!(gets.len(), 1); + assert!(prefers_text(&gets[0]), "headers: {:?}", gets[0].headers); + let store = Store::open(&store_path).unwrap(); + assert_eq!(store.get_body("M1").unwrap().unwrap().content, "hi"); + assert!( + store + .get_body_for("M1", BodyFormat::Text) + .unwrap() + .is_some() + ); + assert!( + store + .get_body_for("M1", BodyFormat::Html) + .unwrap() + .is_none() + ); + + let _ = handle.cmd_tx.send(SyncCommand::Shutdown); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_html_engine_fetches_native_bodies_and_stores_html() { + let (srv, handle, store_path, dir) = body_engine( + "body-html", + vec![body_route("html", "

hi

")], + html_options(), + ); + fetch_m1_body(&handle); + + let gets = m1_body_gets(&srv); + assert_eq!(gets.len(), 1); + assert!( + !gets[0] + .headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("prefer")), + "headers: {:?}", + gets[0].headers + ); + let store = Store::open(&store_path).unwrap(); + let body = store.get_body_for("M1", BodyFormat::Html).unwrap().unwrap(); + assert_eq!(body.content_type, "html"); + assert_eq!(body.content, "

hi

"); + assert!( + store + .get_body_for("M1", BodyFormat::Text) + .unwrap() + .is_none() + ); + + let _ = handle.cmd_tx.send(SyncCommand::Shutdown); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_body_stored_as_text_is_fetched_as_html_once() { + let (srv, handle, store_path, dir) = body_engine( + "body-upgrade", + vec![body_route("html", "

rich

")], + html_options(), + ); + // As an engine from before #1901 left it: fetched as text, no format. + let store = Store::open(&store_path).unwrap(); + store + .put_body( + "M1", + &crate::graph::model::Body { + content_type: "text".into(), + content: "plain".into(), + }, + ) + .unwrap(); + + // The embedder's gate, run as on two opens of the message. + for _ in 0..2 { + if store + .get_body_for("M1", BodyFormat::Html) + .unwrap() + .is_none() + { + fetch_m1_body(&handle); + } + } + + assert_eq!(m1_body_gets(&srv).len(), 1); + let body = store.get_body_for("M1", BodyFormat::Html).unwrap().unwrap(); + assert_eq!(body.content_type, "html"); + assert_eq!(body.content, "

rich

"); + + let _ = handle.cmd_tx.send(SyncCommand::Shutdown); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_native_text_body_fetched_as_html_is_not_fetched_again() { + // A plain-text message's native body is text: it answers the `Html` + // ask, so the gate must not send it back to Graph on every open. + let (srv, handle, store_path, dir) = body_engine( + "body-native-text", + vec![body_route("text", "just text")], + html_options(), + ); + let store = Store::open(&store_path).unwrap(); + for _ in 0..2 { + if store + .get_body_for("M1", BodyFormat::Html) + .unwrap() + .is_none() + { + fetch_m1_body(&handle); + } + } + + assert_eq!(m1_body_gets(&srv).len(), 1); + let body = store.get_body_for("M1", BodyFormat::Html).unwrap().unwrap(); + assert_eq!(body.content_type, "text"); + assert_eq!(body.content, "just text"); + + let _ = handle.cmd_tx.send(SyncCommand::Shutdown); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_html_engine_stores_inline_attachments_content_id() { + let (_srv, handle, store_path, dir) = body_engine( + "body-inline", + vec![get_route( + "/me/messages/M1/attachments", + 200, + r##"{"value":[{"@odata.type":"#microsoft.graph.fileAttachment","id":"A1","name":"logo.png","contentType":"image/png","size":3,"isInline":true,"contentId":"logo@x"}]}"##, + )], + html_options(), + ); + handle + .cmd_tx + .send(SyncCommand::FetchAttachments { + message_id: "M1".into(), + }) + .unwrap(); + wait_for( + &handle.evt_rx, + |e| matches!(e, SyncEvent::AttachmentsUpdated { message_id } if message_id == "M1"), + ); + + let store = Store::open(&store_path).unwrap(); + let atts = store.attachments("M1").unwrap(); + assert_eq!(atts.len(), 1); + assert!(atts[0].is_inline); + assert_eq!(atts[0].content_id.as_deref(), Some("logo@x")); + + let _ = handle.cmd_tx.send(SyncCommand::Shutdown); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn folder_sync_adopts_sentinel_drafts_into_the_real_drafts_folder() { // A draft created before the first folder sync lands under the local From 6d7c5f9ff77d6ed8bd86b1b120f77c08516444a3 Mon Sep 17 00:00:00 2001 From: yeroo Date: Thu, 5 Nov 2026 12:00:58 +0400 Subject: [PATCH 3/4] CLAUDE.md: mail body format (#1901) Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 10fcd6e8..519f4954 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -660,6 +660,29 @@ - `FakeServer::start_limited` gives a route a use limit; a used-up route is skipped for the next match (a send that fails once, then succeeds). +## Mail body format (#1901) + +- `engine::EngineOptions { body_format }` (`graph::model::BodyFormat`, + default `Text`, re-exported from `sync::engine`) is chosen at spawn: + `spawn` runs the default, `spawn_with_options` takes one. `Text` asks + Graph for plain text (`Prefer: outlook.body-content-type="text"`, lookxy + unchanged); `Html` sends no `Prefer`, so a body comes back native (html, + or text for a plain-text message). +- `bodies.fetched_as` records the format `FetchBody` *asked for* + (`Store::put_fetched_body`), not the content type that came back. NULL is + a body not fetched by `FetchBody`: `put_body` (a `createReply` draft), + `create_local_draft`, `update_draft_fields` (which also makes the body + html, as compose writes html), or a row from before the column. + `Store::get_body_for(id, format)` is the body when it satisfies the format: + a recorded format must equal it; NULL with a text body counts as asked + for text (the old engine always did), NULL with html satisfies both. So a + store filled as text upgrades one message per `FetchBody` and a native + text answer to `Html` is not refetched. `FetchBody` itself always goes to + Graph: the caller gates on `get_body_for` (`get_body` still shows + whatever is stored). A `createReply` draft of a plain-text message is + stored as text with no format, so under `Html` it is refetched once. +- Not done: FTS indexes an html body's markup (the triggers copy `content`). + ## Format plugins - A format plugin is a `yppx_*` shared library (`.dll`/`.so`/`.dylib`) From 1853953ba33778c11436323d8774a8f893d8bc88 Mon Sep 17 00:00:00 2001 From: yeroo Date: Thu, 5 Nov 2026 12:10:25 +0400 Subject: [PATCH 4/4] mailcore: a draft's local body is never a refetch candidate (#1901 r1) A body with no recorded fetch format now satisfies both formats when it belongs to a draft. Under Html, a plain-text createReply draft (stored as text) and a draft an older update_draft_fields left as html content under content_type 'text' read as "asked for text", inviting a FetchBody over unsynced edits that a queued SaveDraft would then push back as Graph's old body (m1, m3). A draft is is_draft or filed in the Drafts folder (synced or the local sentinel): a complete delta item resets is_draft to 0, as isDraft is not in MESSAGE_SELECT, so the flag alone would not last past a sync. A body FetchBody recorded keeps the strict rule. The #1901 store tests move to their own body_format_tests module (m2). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 16 ++- mailcore/src/store/mod.rs | 229 ++++++++++++++++++++++++----------- mailcore/src/store/schema.rs | 6 +- mailcore/src/sync/engine.rs | 62 ++++++++++ 4 files changed, 234 insertions(+), 79 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 519f4954..d47e483f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -674,13 +674,17 @@ `create_local_draft`, `update_draft_fields` (which also makes the body html, as compose writes html), or a row from before the column. `Store::get_body_for(id, format)` is the body when it satisfies the format: - a recorded format must equal it; NULL with a text body counts as asked - for text (the old engine always did), NULL with html satisfies both. So a - store filled as text upgrades one message per `FetchBody` and a native - text answer to `Html` is not refetched. `FetchBody` itself always goes to + a recorded format must equal it; NULL satisfies both when the body is + html or a draft's, else counts as asked for text (the old engine always + did). A draft is `is_draft` or filed in the Drafts folder (synced or the + local sentinel): a complete delta item resets `is_draft`, as `isDraft` is + not in `MESSAGE_SELECT`. So a draft's local body (a plain-text + `createReply`, or html an older `update_draft_fields` left under + `content_type = 'text'`) is never refetched over unsynced edits, a store + filled as text upgrades one message per `FetchBody`, and a native text + answer to `Html` is not refetched. `FetchBody` itself always goes to Graph: the caller gates on `get_body_for` (`get_body` still shows - whatever is stored). A `createReply` draft of a plain-text message is - stored as text with no format, so under `Html` it is refetched once. + whatever is stored). - Not done: FTS indexes an html body's markup (the triggers copy `content`). ## Format plugins diff --git a/mailcore/src/store/mod.rs b/mailcore/src/store/mod.rs index e139e03e..aa9713c2 100644 --- a/mailcore/src/store/mod.rs +++ b/mailcore/src/store/mod.rs @@ -1112,18 +1112,23 @@ impl Store { /// and a body is fetched once per format, not on every open. Use /// [`Store::get_body`] to show whatever is stored meanwhile. /// - /// A body satisfies `format` unless it was asked for in the other one. - /// What it was asked for is `fetched_as` when `FetchBody` recorded it; - /// otherwise (`NULL`) a text body counts as asked for text, since an - /// engine from before the column always asked for text, and an html - /// body (a draft written locally, or by `createReply`) as asked for - /// neither, so it satisfies both. So a plain-text message fetched under - /// `Html` (its native body is text) satisfies `Html` and is not fetched - /// again. One edge: a `createReply` draft of a plain-text message comes - /// back as text and is stored with no format, so under `Html` it reads - /// as asked for text and is fetched once more, natively (the same text); - /// compose edits are stored as html (`update_draft_fields`), so they - /// are never invited to be overwritten. + /// A body `FetchBody` recorded (`fetched_as`) satisfies the format it + /// was asked in, so a plain-text message fetched under `Html` (its + /// native body is text) satisfies `Html` and is not fetched again. A + /// body with no recorded format (`NULL`) satisfies both when it is html, + /// or a draft's (below), and otherwise counts as asked for text: an + /// engine from before the column always asked for text. + /// + /// A draft's body with no recorded format is a local write (compose, + /// `createReply`), never a refetch candidate: a `FetchBody` over it + /// would replace unsynced edits, which a queued `SaveDraft` then pushes + /// back as Graph's old body. That covers a `createReply` draft of a + /// plain-text message (stored as text) and a draft an older build's + /// `update_draft_fields` left as html content under `content_type = + /// 'text'`. A draft is a message marked `is_draft` or filed in the + /// Drafts folder (the synced one, or the local sentinel): a complete + /// delta item resets `is_draft` to 0, as `isDraft` is not in + /// `MESSAGE_SELECT`, so the flag alone would not last past a sync. pub fn get_body_for( &self, message_id: &str, @@ -1132,8 +1137,13 @@ impl Store { let row = self .conn .query_row( - "SELECT content_type, content, fetched_as FROM bodies WHERE message_id = ?1", - params![message_id], + "SELECT b.content_type, b.content, b.fetched_as, + COALESCE(m.is_draft = 1 OR f.well_known_name = 'drafts' OR m.folder_id = ?2, 0) + FROM bodies b + LEFT JOIN messages m ON m.id = b.message_id + LEFT JOIN folders f ON f.id = m.folder_id + WHERE b.message_id = ?1", + params![message_id, LOCAL_DRAFTS_SENTINEL_FOLDER_ID], |row| { Ok(( Body { @@ -1141,6 +1151,7 @@ impl Store { content: row.get(1)?, }, row.get::<_, Option>(2)?, + row.get::<_, bool>(3)?, )) }, ) @@ -1152,13 +1163,17 @@ impl Store { Err(e) } })?; - let Some((body, fetched_as)) = row else { + let Some((body, fetched_as, draft)) = row else { return Ok(None); }; let satisfies = match fetched_as.as_deref() { // A value this build does not know is not `format`: refetch. Some(asked) => BodyFormat::parse(asked) == Some(format), - None => body.content_type.eq_ignore_ascii_case("html") || format == BodyFormat::Text, + None => { + draft + || body.content_type.eq_ignore_ascii_case("html") + || format == BodyFormat::Text + } }; Ok(satisfies.then_some(body)) } @@ -4435,7 +4450,52 @@ mod local_id_tests { assert_eq!(s.draft(&local).unwrap().unwrap().0.subject, "From Graph"); } - // --- Body format (#1901) ------------------------------------------------ + #[test] + fn a_database_from_before_local_id_is_migrated() { + let dir = std::env::temp_dir().join(format!( + "lookxy-store-local-id-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("mail.db"); + let local = { + let s = Store::open(&path).unwrap(); + let local = s.create_local_draft("Old", "", "", "body").unwrap(); + // Take the database back to before the column existed. + s.conn + .execute_batch( + "DROP INDEX idx_messages_local_id; + ALTER TABLE messages DROP COLUMN local_id;", + ) + .unwrap(); + local + }; + let s = Store::open(&path).unwrap(); + let has_index: i64 = s + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_messages_local_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(has_index, 1); + s.reconcile_id(&local, "G").unwrap(); + assert_eq!(s.current_id(&local).unwrap(), "G"); + assert_eq!(s.draft(&local).unwrap().unwrap().0.id, "G"); + drop(s); + // Opening again (column and index already there) is fine. + let s = Store::open(&path).unwrap(); + assert_eq!(s.current_id(&local).unwrap(), "G"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod body_format_tests { + use super::*; fn text_body(content: &str) -> Body { Body { @@ -4451,13 +4511,26 @@ mod local_id_tests { } } - /// A store holding one message `M` (in Drafts, so `update_draft_fields` - /// reaches it), for bodies to hang off. + /// A store holding one Inbox message `M`, not a draft, for bodies to + /// hang off. fn store_with_message() -> Store { let s = Store::open_in_memory().unwrap(); - let folder = s.drafts_folder_id().unwrap(); - let m = Message { - id: "M".into(), + s.upsert_folder(&MailFolder { + id: "INBOX".into(), + display_name: "Inbox".into(), + parent_id: None, + total_count: 0, + unread_count: 0, + well_known_name: Some("inbox".into()), + }) + .unwrap(); + s.upsert_message("INBOX", &message("M", false)).unwrap(); + s + } + + fn message(id: &str, is_draft: bool) -> Message { + Message { + id: id.into(), conversation_id: String::new(), subject: "S".into(), from: Recipient::default(), @@ -4470,12 +4543,10 @@ mod local_id_tests { has_attachments: false, importance: "normal".into(), preview: String::new(), - is_draft: false, + is_draft, is_meeting_request: false, categories: Vec::new(), - }; - s.upsert_message(&folder, &m).unwrap(); - s + } } fn satisfies(s: &Store, id: &str) -> (bool, bool) { @@ -4568,6 +4639,11 @@ mod local_id_tests { let s = store_with_message(); s.put_fetched_body("M", &text_body("server text"), BodyFormat::Text) .unwrap(); + // Recorded as a text fetch, the strict rule holds even for a draft. + s.conn + .execute("UPDATE messages SET is_draft = 1 WHERE id = 'M'", []) + .unwrap(); + assert_eq!(satisfies(&s, "M"), (true, false)); s.update_draft_fields("M", "S", "", "", "", "

my edit

") .unwrap(); let b = s.get_body_for("M", BodyFormat::Html).unwrap().unwrap(); @@ -4595,18 +4671,22 @@ mod local_id_tests { let path = dir.join("mail.db"); let id = { let s = Store::open(&path).unwrap(); - let id = s.create_local_draft("Old", "", "", "body").unwrap(); - s.conn - .execute( - "UPDATE bodies SET content_type = 'text' WHERE message_id = ?1", - params![id], - ) - .unwrap(); + s.upsert_folder(&MailFolder { + id: "INBOX".into(), + display_name: "Inbox".into(), + parent_id: None, + total_count: 0, + unread_count: 0, + well_known_name: Some("inbox".into()), + }) + .unwrap(); + s.upsert_message("INBOX", &message("M", false)).unwrap(); + s.put_body("M", &text_body("old")).unwrap(); // Take the database back to before the column existed. s.conn .execute_batch("ALTER TABLE bodies DROP COLUMN fetched_as;") .unwrap(); - id + "M".to_string() }; let s = Store::open(&path).unwrap(); // The old row reads as an older engine's text fetch. @@ -4622,45 +4702,54 @@ mod local_id_tests { let _ = std::fs::remove_dir_all(&dir); } + /// The Drafts folder as Graph syncs it. + fn synced_drafts(s: &Store) { + s.upsert_folder(&MailFolder { + id: "DRAFTS".into(), + display_name: "Drafts".into(), + parent_id: None, + total_count: 0, + unread_count: 0, + well_known_name: Some("drafts".into()), + }) + .unwrap(); + } + #[test] - fn a_database_from_before_local_id_is_migrated() { - let dir = std::env::temp_dir().join(format!( - "lookxy-store-local-id-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("mail.db"); - let local = { - let s = Store::open(&path).unwrap(); - let local = s.create_local_draft("Old", "", "", "body").unwrap(); - // Take the database back to before the column existed. - s.conn - .execute_batch( - "DROP INDEX idx_messages_local_id; - ALTER TABLE messages DROP COLUMN local_id;", - ) - .unwrap(); - local - }; - let s = Store::open(&path).unwrap(); - let has_index: i64 = s - .conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_messages_local_id'", + fn a_legacy_edited_draft_is_never_a_refetch_candidate() { + // An older build's `update_draft_fields` kept `content_type = 'text'` + // under the html it wrote: a FetchBody over it would lose the edit. + let s = store_with_message(); + synced_drafts(&s); + s.upsert_message("DRAFTS", &message("D", true)).unwrap(); + s.conn + .execute( + "INSERT INTO bodies (message_id, content_type, content) + VALUES ('D', 'text', '

unsynced edit

')", [], - |r| r.get(0), ) .unwrap(); - assert_eq!(has_index, 1); - s.reconcile_id(&local, "G").unwrap(); - assert_eq!(s.current_id(&local).unwrap(), "G"); - assert_eq!(s.draft(&local).unwrap().unwrap().0.id, "G"); - drop(s); - // Opening again (column and index already there) is fine. - let s = Store::open(&path).unwrap(); - assert_eq!(s.current_id(&local).unwrap(), "G"); - drop(s); - let _ = std::fs::remove_dir_all(&dir); + assert_eq!(satisfies(&s, "D"), (true, true)); + // A complete delta item writes `is_draft = 0` (`isDraft` is not + // selected): the Drafts folder still says it is a draft. + s.upsert_message("DRAFTS", &message("D", false)).unwrap(); + assert_eq!(satisfies(&s, "D"), (true, true)); + // A message elsewhere with the same body still upgrades. + s.put_body("M", &text_body("old")).unwrap(); + assert_eq!(satisfies(&s, "M"), (true, false)); + } + + #[test] + fn a_draft_under_the_local_sentinel_is_a_draft() { + let s = Store::open_in_memory().unwrap(); + let id = s.create_local_draft("S", "", "", "").unwrap(); + s.conn + .execute( + "UPDATE messages SET is_draft = 0 WHERE id = ?1", + params![id], + ) + .unwrap(); + s.put_body(&id, &text_body("t")).unwrap(); + assert_eq!(satisfies(&s, &id), (true, true)); } } diff --git a/mailcore/src/store/schema.rs b/mailcore/src/store/schema.rs index 2cf87ab8..2d1351b2 100644 --- a/mailcore/src/store/schema.rs +++ b/mailcore/src/store/schema.rs @@ -70,9 +70,9 @@ CREATE TABLE IF NOT EXISTS contacts ( ); -- `fetched_as` is the format `FetchBody` asked Graph for (`text`/`html`, --- #1901); NULL for a body written locally (drafts, createReply) or by an --- engine from before the column (which always asked for text). See --- `Store::get_body_for`. +-- #1901); NULL for a body written locally (drafts, createReply), which +-- `Store::get_body_for` never offers for a refetch, or by an engine from +-- before the column (which always asked for text). CREATE TABLE IF NOT EXISTS bodies ( message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, content_type TEXT NOT NULL DEFAULT '', diff --git a/mailcore/src/sync/engine.rs b/mailcore/src/sync/engine.rs index 66d0e418..df8636b6 100644 --- a/mailcore/src/sync/engine.rs +++ b/mailcore/src/sync/engine.rs @@ -4153,6 +4153,68 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_plain_text_reply_draft_is_not_a_refetch_candidate() { + // `createReply` of a plain-text message comes back as text, stored + // with no fetch format; a FetchBody over the draft compose just + // opened would lose the user's edits. The response here carries no + // `isDraft` (so `is_draft` is 0, as a later complete delta item + // leaves it anyway): the Drafts folder is what marks it a draft. + let (srv, handle, store_path, dir) = body_engine( + "body-reply-draft", + vec![ + Route { + method: "POST".into(), + path_prefix: "/me/messages/M1/createReply".into(), + status: 200, + headers: vec![], + body: r#"{"id":"DRAFT1","conversationId":"C1","subject":"Re: Hello","from":{"emailAddress":{"name":"Alice","address":"alice@x"}},"toRecipients":[],"ccRecipients":[],"receivedDateTime":"","sentDateTime":"","isRead":true,"hasAttachments":false,"importance":"normal","bodyPreview":""}"#.into(), + }, + get_route( + "/me/messages/DRAFT1?$select=body", + 200, + r#"{"body":{"contentType":"text","content":"> quoted"}}"#, + ), + ], + html_options(), + ); + handle + .cmd_tx + .send(SyncCommand::ComposeReply { + id: "M1".into(), + all: false, + }) + .unwrap(); + wait_for( + &handle.evt_rx, + |e| matches!(e, SyncEvent::DraftReady { id } if id == "DRAFT1"), + ); + + let store = Store::open(&store_path).unwrap(); + assert!(!store.draft("DRAFT1").unwrap().unwrap().0.is_draft); + let body = store + .get_body_for("DRAFT1", BodyFormat::Html) + .unwrap() + .expect("a reply draft's body satisfies Html"); + assert_eq!(body.content_type, "text"); + assert_eq!(body.content, "> quoted"); + assert!( + store + .get_body_for("DRAFT1", BodyFormat::Text) + .unwrap() + .is_some() + ); + let draft_gets = srv + .requests() + .into_iter() + .filter(|r| r.path.starts_with("/me/messages/DRAFT1")) + .count(); + assert_eq!(draft_gets, 1, "only store_composed_draft's own GET"); + + let _ = handle.cmd_tx.send(SyncCommand::Shutdown); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn an_html_engine_stores_inline_attachments_content_id() { let (_srv, handle, store_path, dir) = body_engine(