diff --git a/.github/workflows/connector-live-e2e.yml b/.github/workflows/connector-live-e2e.yml index ec11d3d9..988bfaf0 100644 --- a/.github/workflows/connector-live-e2e.yml +++ b/.github/workflows/connector-live-e2e.yml @@ -266,6 +266,7 @@ jobs: LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON: ${{ secrets.LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON }} LOCALITY_GMAIL_LIVE_TO_EMAIL: ${{ secrets.LOCALITY_GMAIL_LIVE_TO_EMAIL }} LOCALITY_LIVE_GMAIL_VFS: "1" + LOCALITY_LIVE_GMAIL_SEND: "1" LOCALITY_LIVE_FORCE_OAUTH_REFRESH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_oauth_refresh == true && '1' || '0' }} LOCALITY_LIVE_ROTATED_CREDENTIAL_OUTPUT: ${{ github.event_name == 'workflow_dispatch' && inputs.persist_rotated_oauth_secrets == true && format('{0}/locality-gmail-live-credential.json', runner.temp) || '' }} run: tests/live_gmail_vfs_roundtrip.sh diff --git a/apps/desktop/src-tauri/src/agent_guidance.rs b/apps/desktop/src-tauri/src/agent_guidance.rs index 7f2485dc..8b708585 100644 --- a/apps/desktop/src-tauri/src/agent_guidance.rs +++ b/apps/desktop/src-tauri/src/agent_guidance.rs @@ -435,6 +435,7 @@ Connected sources can include Notion, Google Docs, Google Calendar, Gmail, Linea - Unless the user asked you to apply edits remotely, leave edits pending for Locality review and tell the user what changed. - If desktop Live Mode is on, safe local edits may sync automatically. Use `loc live-mode status ` to inspect state. Do not run routine `loc pull` or `loc push` after every edit. - If the user asks you to sync, publish, send, update the source, or apply the edit remotely, run `loc diff ` first, then `loc push -y` for safe plans. +- If Live Mode is paused, conflicted, or review-needed, inspect with `loc status ` and `loc diff ` before pushing. - If push says the remote changed since last sync, run `loc pull `, resolve any inline conflict markers in the Markdown, rerun `loc diff `, then push again. ## Creating Notion Content @@ -453,7 +454,9 @@ Connected sources can include Notion, Google Docs, Google Calendar, Gmail, Linea - Notion pages are directories with `page.md`; child pages live as child directories. Preserve Locality identity frontmatter, block IDs, directives starting with `::loc{{`, `_schema.yaml`, `AGENTS.md`, and `CLAUDE.md` unless explicitly asked. - Google Docs files are writable Markdown documents. Preserve Locality frontmatter and follow the mount-local `AGENTS.md` for supported formatting. - Calendar mounts expose drafts for new events. Create and edit Calendar drafts only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff ` and explicit push when the user asks to publish. -- Gmail mounts expose `draft/` for unsent drafts and `outbox/` for direct sends. Use `outbox/` only when the user explicitly asks to send now; otherwise use `draft/`. Create and edit Gmail outbound files only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff ` and explicit push when the user asks to send. +- Gmail mounts expose `draft/` for remote Gmail drafts and local draft creates. Leave messages in `draft/` when the user asks to draft or revise. +- Use `outbox/` only when the user explicitly asks to send now. Moving an existing draft into `outbox/` sends that draft after applying local edits. +- Create and edit Gmail outbound files only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff ` and explicit push when the user asks to send. - Linear issue edits and Linear status moves are supported through the mounted issue files when the mount-local `AGENTS.md` says so. Inspect with `loc diff ` before pushing. - Slack and Granola mounts are read-only. Do not edit, create, delete, rename, or push files there. @@ -1096,6 +1099,7 @@ mod tests { "loc pull ", "loc live-mode status ", "loc connect --no-browser", + "If Live Mode is paused, conflicted, or review-needed", ] { assert!(skill.contains(command), "missing command {command}"); } @@ -1110,11 +1114,14 @@ mod tests { assert!(skill.contains("If initial search gives no hits, refine the query and browse directory names before concluding context is unavailable")); assert!(skill.contains("If useful results are outside a user-provided path or source scope, do not read them until the user permits it")); assert!(skill.contains("Calendar mounts expose drafts for new events")); - assert!(skill.contains("Gmail mounts expose `draft/` for unsent drafts")); - assert!(skill.contains("`outbox/` for direct sends")); - assert!(skill.contains( - "Use `outbox/` only when the user explicitly asks to send now; otherwise use `draft/`." - )); + let expected_gmail_guidance = "\ +- Gmail mounts expose `draft/` for remote Gmail drafts and local draft creates. Leave messages in `draft/` when the user asks to draft or revise. +- Use `outbox/` only when the user explicitly asks to send now. Moving an existing draft into `outbox/` sends that draft after applying local edits. +- Create and edit Gmail outbound files only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff ` and explicit push when the user asks to send."; + assert!( + skill.contains(expected_gmail_guidance), + "missing exact Gmail guidance:\n{skill}" + ); assert!(skill.contains("Linear issue edits")); assert!(skill.contains("Linear status moves")); assert!(skill.contains("Slack and Granola mounts are read-only")); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index cd558f86..7d8ab300 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -735,7 +735,7 @@ function suggestedAgentPrompt(mountPath: string, connector: OnboardingConnectorI case "google-calendar": return `Use Locality to inspect my Google Calendar source. Open the files under ${mountPath}, review calendar events with normal file tools, and prepare new event drafts for Locality review before creating them.`; case "gmail": - return `Use Locality to inspect my Gmail source. Open the files under ${mountPath}, search mail with normal file tools, use draft/ for unsent Gmail drafts, and use outbox/ only when I explicitly ask you to send mail. Leave outbound changes for Locality review.`; + return `Use Locality to inspect my Gmail source. Open the files under ${mountPath}, search mail with normal file tools, leave messages in draft/ when I ask to draft or revise, and use outbox/ only when I explicitly ask you to send now. Leave outbound changes for Locality review.`; case "linear": return `Use Locality to edit my Linear issues. Open the files under ${mountPath}, update issue Markdown and editable frontmatter, and leave changes pending for Locality review before pushing.`; case "notion": @@ -3980,7 +3980,7 @@ function AddSourceDialog({ { id: "gmail", name: "Gmail", - description: "Inbox and sent as readable files, draft/ for Gmail drafts, outbox/ for reviewed direct sends.", + description: "Inbox and sent as readable files, draft/ for Gmail drafts and local creates, outbox/ for local-only reviewed sends.", status: sourceConnectorStatus(snapshot, "gmail"), keywords: ["gmail", "mail", "email", "inbox", "drafts"], mounted: sourceMounted(snapshot, "gmail"), @@ -7841,7 +7841,7 @@ const onboardingConnectorCards: OnboardingConnectorCard[] = [ { connector: "gmail", title: "Gmail", - description: "Inbox and sent mail as local files, draft/ for Gmail drafts, outbox/ for reviewed sends.", + description: "Inbox and sent mail as local files, draft/ for Gmail drafts and local creates, outbox/ for local-only reviewed sends.", }, { connector: "granola", diff --git a/connectors/registry.json b/connectors/registry.json index a64eb05d..2088f6e0 100644 --- a/connectors/registry.json +++ b/connectors/registry.json @@ -272,7 +272,7 @@ }, "capabilities": { "supports_block_updates": false, - "supports_entity_body_updates": false, + "supports_entity_body_updates": true, "supports_databases": false, "supports_oauth": true, "supports_remote_observation": true, @@ -281,14 +281,19 @@ "supports_undo": false, "supports_batch_observation": false }, - "push_operations": ["create_entity"], + "push_operations": [ + "create_entity", + "update_properties", + "update_entity_body", + "move_entity" + ], "membership_operations": [], "projection": { "source_root_create_parent_kind": null, "create_entity_parent_kinds": ["directory"], "move_entity_parent_kinds": ["directory"], - "body_diff_mode": "block", - "virtual_rename_policy": "filename_derived", + "body_diff_mode": "whole_entity", + "virtual_rename_policy": "preserve_canonical", "periodic_discovery_seconds": null, "max_background_discovery_workers": 4 }, diff --git a/crates/loc-cli/tests/mount.rs b/crates/loc-cli/tests/mount.rs index 330c00c4..8484cabd 100644 --- a/crates/loc-cli/tests/mount.rs +++ b/crates/loc-cli/tests/mount.rs @@ -121,7 +121,7 @@ fn mount_writes_gmail_attachment_guidance() { assert!(agents.contains("`filename`, `mime_type`, `size`, `attachment_id`, and `path`")); assert!(agents.contains(".loc/gmail/attachments/...")); assert!(agents.contains("use the frontmatter path exactly")); - assert!(agents.contains("Gmail draft creation does not support outbound attachments yet")); + assert!(agents.contains("Gmail outbound attachments are not supported yet")); } #[test] diff --git a/crates/loc-cli/tests/status.rs b/crates/loc-cli/tests/status.rs index e619d9d9..8a55c270 100644 --- a/crates/loc-cli/tests/status.rs +++ b/crates/loc-cli/tests/status.rs @@ -1545,6 +1545,10 @@ impl ShadowRepository for BulkOnlyStatusStore { ) -> StoreResult> { self.inner.get_shadow_record(mount_id, entity_id) } + + fn delete_shadow(&mut self, mount_id: &MountId, entity_id: &RemoteId) -> StoreResult<()> { + self.inner.delete_shadow(mount_id, entity_id) + } } impl JournalRepository for BulkOnlyStatusStore { diff --git a/crates/locality-gmail/src/client.rs b/crates/locality-gmail/src/client.rs index bbfb5588..1a43c6fd 100644 --- a/crates/locality-gmail/src/client.rs +++ b/crates/locality-gmail/src/client.rs @@ -10,8 +10,9 @@ use serde::Serialize; use serde::de::DeserializeOwned; use crate::dto::{ - GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList, - GmailMessagePartBody, GmailMessageSendRequest, GmailThread, GmailThreadList, + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailDraftSendRequest, + GmailDraftUpdateRequest, GmailMessage, GmailMessageList, GmailMessagePartBody, + GmailMessageSendRequest, GmailThread, GmailThreadList, }; pub const DEFAULT_GMAIL_API_BASE_URL: &str = "https://gmail.googleapis.com/gmail/v1"; @@ -43,7 +44,19 @@ pub trait GmailApi: std::fmt::Debug + Send + Sync { message_id: &str, attachment_id: &str, ) -> LocalityResult; + fn list_drafts( + &self, + max_results: u32, + page_token: Option<&str>, + query: Option<&str>, + ) -> LocalityResult; + fn get_draft_full(&self, draft_id: &str) -> LocalityResult; fn create_draft(&self, request: GmailDraftCreateRequest) -> LocalityResult; + fn update_draft( + &self, + draft_id: &str, + request: GmailDraftUpdateRequest, + ) -> LocalityResult; fn send_message(&self, request: GmailMessageSendRequest) -> LocalityResult; fn send_draft(&self, request: GmailDraftSendRequest) -> LocalityResult; } @@ -111,6 +124,21 @@ impl HttpGmailApiClient { context, ) } + + fn put_json_with_context(&self, path: &str, body: &B, context: &str) -> LocalityResult + where + T: DeserializeOwned, + B: Serialize + ?Sized, + { + decode_response( + self.client + .put(format!("{}{}", self.base_url, path)) + .bearer_auth(&self.access_token) + .json(body) + .send(), + context, + ) + } } impl GmailApi for HttpGmailApiClient { @@ -199,10 +227,47 @@ impl GmailApi for HttpGmailApiClient { ) } + fn list_drafts( + &self, + max_results: u32, + page_token: Option<&str>, + search_query: Option<&str>, + ) -> LocalityResult { + let mut params = vec![("maxResults".to_string(), max_results.to_string())]; + if let Some(page_token) = page_token { + params.push(("pageToken".to_string(), page_token.to_string())); + } + if let Some(search_query) = search_query { + params.push(("q".to_string(), search_query.to_string())); + } + self.get_json("/users/me/drafts", params) + } + + fn get_draft_full(&self, draft_id: &str) -> LocalityResult { + let draft_id = percent_encode_path_segment(draft_id); + self.get_json( + &format!("/users/me/drafts/{draft_id}"), + vec![("format".to_string(), "full".to_string())], + ) + } + fn create_draft(&self, request: GmailDraftCreateRequest) -> LocalityResult { self.post_json_with_context("/users/me/drafts", &request, "gmail draft create") } + fn update_draft( + &self, + draft_id: &str, + request: GmailDraftUpdateRequest, + ) -> LocalityResult { + let draft_id = percent_encode_path_segment(draft_id); + self.put_json_with_context( + &format!("/users/me/drafts/{draft_id}"), + &request, + "gmail draft update", + ) + } + fn send_message(&self, request: GmailMessageSendRequest) -> LocalityResult { self.post_json_with_context("/users/me/messages/send", &request, "gmail message send") } @@ -273,7 +338,7 @@ mod tests { use locality_core::LocalityError; - use crate::dto::GmailMessageSendRequest; + use crate::dto::{GmailDraftUpdateRequest, GmailMessageSendRequest, GmailRawMessage}; use super::{GmailApi, HttpGmailApiClient}; @@ -399,6 +464,46 @@ mod tests { assert!(!target.contains(' '), "{target}"); } + #[test] + fn list_drafts_calls_gmail_drafts_endpoint_with_max_results() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"drafts":[{"id":"draft-1","message":{"id":"msg-1","threadId":"thread-1"}}],"resultSizeEstimate":1}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + let drafts = client + .list_drafts(100, None, None) + .expect("draft list response"); + + assert_eq!(drafts.drafts[0].id, "draft-1"); + let request = request_rx.recv().expect("request line"); + server.join().expect("server exits"); + let target = request.split_whitespace().nth(1).expect("request target"); + assert_eq!(target, "/users/me/drafts?maxResults=100"); + } + + #[test] + fn get_draft_full_calls_gmail_draft_endpoint_with_full_format() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"id":"draft/1 space","message":{"id":"msg-1","threadId":"thread-1"}}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + let draft = client + .get_draft_full("draft/1 space") + .expect("draft response"); + + assert_eq!(draft.id, "draft/1 space"); + let request = request_rx.recv().expect("request line"); + server.join().expect("server exits"); + let target = request.split_whitespace().nth(1).expect("request target"); + assert_eq!(target, "/users/me/drafts/draft%2F1%20space?format=full"); + assert!(!target.contains("draft/1"), "{target}"); + assert!(!target.contains(' '), "{target}"); + } + #[test] fn http_errors_map_google_status_semantics() { assert!(matches!( @@ -500,6 +605,43 @@ mod tests { ); } + #[test] + fn update_draft_puts_raw_body_to_gmail_draft_endpoint() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"id":"draft/1","message":{"id":"updated-msg-1","threadId":"thread-1"}}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + let updated = client + .update_draft( + "draft/1", + GmailDraftUpdateRequest { + message: GmailRawMessage { + raw: "updated-raw".to_string(), + }, + }, + ) + .expect("draft update response"); + + assert_eq!(updated.message.id, "updated-msg-1"); + let request = request_rx.recv().expect("request"); + server.join().expect("server exits"); + assert!( + request.starts_with("PUT /users/me/drafts/draft%2F1 HTTP/1.1"), + "{request}" + ); + let request_lowercase = request.to_ascii_lowercase(); + assert!( + request_lowercase.contains("authorization: bearer access-token"), + "{request}" + ); + assert!( + request.ends_with(r#"{"message":{"raw":"updated-raw"}}"#), + "{request}" + ); + } + fn request_error_for_status(status_line: &'static str, body: &'static str) -> LocalityError { let (base_url, request_rx, server) = spawn_response_server(status_line, body); let client = HttpGmailApiClient::with_base_url("access-token", base_url); diff --git a/crates/locality-gmail/src/connector.rs b/crates/locality-gmail/src/connector.rs index 3545891b..0b0f250e 100644 --- a/crates/locality-gmail/src/connector.rs +++ b/crates/locality-gmail/src/connector.rs @@ -23,13 +23,14 @@ use serde::{Deserialize, Serialize}; use crate::client::{GmailApi, HttpGmailApiClient}; use crate::dto::{ - GmailDraftCreateRequest, GmailMessage, GmailMessageSendRequest, GmailRawMessage, GmailThread, - header_map, + GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailDraftUpdateRequest, + GmailMessage, GmailMessageSendRequest, GmailRawMessage, GmailThread, header_map, }; use crate::oauth::GMAIL_CONNECTOR_ID; use crate::render::{ GmailDraftDocument, GmailNativeBundle, GmailThreadMessageNativeBundle, GmailThreadNativeBundle, - build_draft_mime_with_message_id, message_frontmatter, parse_thread_message_remote_id, + build_draft_mime_with_message_id, draft_remote_id, message_frontmatter, + message_frontmatter_with_entity_id, parse_draft_remote_id, parse_thread_message_remote_id, parse_thread_remote_id, raw_message_base64url, remote_version, render_gmail_message, render_gmail_thread, render_gmail_thread_message, thread_message_remote_id, thread_remote_id, thread_remote_version, @@ -112,7 +113,7 @@ impl Connector for GmailConnector { fn capabilities(&self) -> ConnectorCapabilities { ConnectorCapabilities { supports_block_updates: false, - supports_entity_body_updates: false, + supports_entity_body_updates: true, supports_databases: false, supports_oauth: true, supports_remote_observation: true, @@ -124,7 +125,14 @@ impl Connector for GmailConnector { } fn supported_push_operations(&self) -> BTreeSet { - [PushOperationKind::CreateEntity].into_iter().collect() + [ + PushOperationKind::CreateEntity, + PushOperationKind::UpdateProperties, + PushOperationKind::UpdateEntityBody, + PushOperationKind::MoveEntity, + ] + .into_iter() + .collect() } fn enumerate(&self, request: EnumerateRequest) -> LocalityResult> { @@ -146,12 +154,10 @@ impl Connector for GmailConnector { "sent", Path::new("sent"), )?); - entries.extend(list_label_entries( + entries.extend(list_draft_entries( self.api.as_ref(), &self.config.settings, &request.mount_id, - "DRAFT", - "draft", Path::new("draft"), )?); return Ok(entries); @@ -174,12 +180,10 @@ impl Connector for GmailConnector { "sent", Path::new("sent"), )?); - entries.extend(list_label_entries( + entries.extend(list_draft_entries( self.api.as_ref(), &self.config.settings, &request.mount_id, - "DRAFT", - "draft", Path::new("draft"), )?); Ok(entries) @@ -237,12 +241,10 @@ impl Connector for GmailConnector { ChildContainer::DirectoryChildren(remote_id) if remote_id.as_str() == DRAFT_FOLDER_ID => { - list_label_entries( + list_draft_entries( self.api.as_ref(), &self.config.settings, &request.mount_id, - "DRAFT", - "draft", &request.parent_path, )? } @@ -350,6 +352,26 @@ impl Connector for GmailConnector { .with_raw_metadata_json(gmail_thread_metadata_json(&thread, &mailbox))); } + if let Some(draft_id) = parse_draft_remote_id(&request.remote_id) { + let draft = self.api.get_draft_full(draft_id)?; + let entry = draft_entry( + &request.mount_id, + Path::new("draft"), + draft.id.clone(), + draft.message.clone(), + )?; + return Ok(RemoteObservation::new( + request.mount_id, + draft_remote_id(&draft.id), + EntityKind::Page, + entry.title, + entry.path, + ) + .with_parent(RemoteId::new(DRAFT_FOLDER_ID)) + .with_remote_version(RemoteVersion::new(remote_version(&draft.message))) + .with_raw_metadata_json(gmail_message_metadata_json(&draft.message, "draft", None))); + } + let message = self.api.get_message_metadata(request.remote_id.as_str())?; let mailbox = mailbox_from_labels(&message.label_ids); let parent_id = mailbox_folder_id(mailbox); @@ -408,9 +430,28 @@ impl Connector for GmailConnector { }); } + if let Some(draft_id) = parse_draft_remote_id(&request.remote_id) { + let draft = self.api.get_draft_full(draft_id)?; + let remote_id = draft_remote_id(&draft.id); + let bundle = GmailNativeBundle { + mailbox: "draft".to_string(), + draft_id: Some(draft.id), + message: draft.message, + }; + let raw = serde_json::to_vec(&bundle).map_err(|error| { + LocalityError::Io(format!("gmail draft native encode failed: {error}")) + })?; + return Ok(NativeEntity { + remote_id, + kind: "gmail_message".to_string(), + raw, + }); + } + let message = self.api.get_message_full(request.remote_id.as_str())?; let bundle = GmailNativeBundle { mailbox: mailbox_from_labels(&message.label_ids).to_string(), + draft_id: None, message, }; let raw = serde_json::to_vec(&bundle) @@ -466,85 +507,196 @@ impl Connector for GmailConnector { fn apply(&self, request: ApplyPlanRequest<'_>) -> LocalityResult { let mut changed_remote_ids = Vec::new(); let mut effects = Vec::new(); + let mut draft_mutations = BTreeMap::::new(); for (index, operation) in request.plan.operations.iter().enumerate() { let operation_id = request.operation_ids.get(index).cloned().ok_or_else(|| { LocalityError::InvalidState("missing operation id".to_string()) })?; - let PushOperation::CreateEntity { - parent_id, - parent_kind, - parent_workspace, - title, - properties, - body, - source_path, - } = operation - else { - return Err(LocalityError::Unsupported("gmail push operation")); - }; - let outbound_target = outbound_target_from_create( - parent_id, - parent_kind, - *parent_workspace, - source_path, + + match operation { + PushOperation::CreateEntity { + parent_id, + parent_kind, + parent_workspace, + title, + properties, + body, + source_path, + } => { + let outbound_target = outbound_target_from_create( + parent_id, + parent_kind, + *parent_workspace, + source_path, + )?; + + let message_id = locality_message_id(request.push_id, &operation_id); + if let Some(sent) = + find_sent_message_by_message_id(self.api.as_ref(), &message_id)? + { + let sent_id = RemoteId::new(sent.id); + changed_remote_ids.push(sent_id.clone()); + effects.push(JournalApplyEffect::CreatedEntity { + operation_id, + operation_index: index, + parent_id: RemoteId::new(SENT_FOLDER_ID), + entity_id: sent_id, + }); + continue; + } + + let draft = draft_from_push_create(title, properties, body)?; + let mime = build_draft_mime_with_message_id(&draft, Some(&message_id))?; + let raw = raw_message_base64url(&mime); + match outbound_target { + OutboundTarget::Draft => { + let created = self.api.create_draft(GmailDraftCreateRequest { + message: GmailRawMessage { raw }, + })?; + let created_draft_id = draft_remote_id(&created.id); + changed_remote_ids.push(created_draft_id.clone()); + effects.push(JournalApplyEffect::CreatedEntity { + operation_id, + operation_index: index, + parent_id: RemoteId::new(DRAFT_FOLDER_ID), + entity_id: created_draft_id, + }); + } + OutboundTarget::Send => { + let sent = match self.api.send_message(GmailMessageSendRequest { raw }) + { + Ok(sent) => sent, + Err(send_error) => { + match find_sent_message_by_message_id( + self.api.as_ref(), + &message_id, + ) { + Ok(Some(sent)) => sent, + Ok(None) => return Err(send_error), + Err(lookup_error) => { + return Err(LocalityError::Io(format!( + "gmail send ambiguous after send failure; sent lookup failed: {lookup_error}" + ))); + } + } + } + }; + let sent_id = RemoteId::new(sent.id); + changed_remote_ids.push(sent_id.clone()); + effects.push(JournalApplyEffect::CreatedEntity { + operation_id, + operation_index: index, + parent_id: RemoteId::new(SENT_FOLDER_ID), + entity_id: sent_id, + }); + } + } + } + PushOperation::UpdateProperties { + entity_id, + properties, + } => { + let Some(draft_id) = parse_draft_remote_id(entity_id).map(str::to_string) + else { + return Err(LocalityError::Unsupported("gmail push operation")); + }; + let mutation = draft_mutation( + &mut draft_mutations, + entity_id, + &draft_id, + index, + operation_id, + ); + mutation.properties.extend(properties.clone()); + } + PushOperation::UpdateEntityBody { entity_id, body } => { + let Some(draft_id) = parse_draft_remote_id(entity_id).map(str::to_string) + else { + return Err(LocalityError::Unsupported("gmail push operation")); + }; + let mutation = draft_mutation( + &mut draft_mutations, + entity_id, + &draft_id, + index, + operation_id, + ); + mutation.body = Some(body.clone()); + } + PushOperation::MoveEntity { + entity_id, + new_parent_id, + new_title, + .. + } => { + let Some(draft_id) = parse_draft_remote_id(entity_id).map(str::to_string) + else { + return Err(LocalityError::Unsupported("gmail push operation")); + }; + if new_parent_id.as_str() != OUTBOX_FOLDER_ID { + return Err(LocalityError::Unsupported("gmail draft move parent")); + } + let mutation = draft_mutation( + &mut draft_mutations, + entity_id, + &draft_id, + index, + operation_id.clone(), + ); + mutation.move_to_outbox = true; + mutation.title = Some(new_title.clone()); + mutation.operation_index = index; + mutation.operation_id = Some(operation_id); + } + _ => return Err(LocalityError::Unsupported("gmail push operation")), + } + } + + for mutation in draft_mutations.into_values() { + let current = self.api.get_draft_full(&mutation.draft_id)?; + let mut draft_seed = draft_document_from_remote_draft(¤t)?; + apply_draft_mutation( + &mut draft_seed.document, + &mutation, + Some(&draft_seed.baseline_title), + )?; + update_gmail_draft_from_document( + self.api.as_ref(), + &mutation.draft_id, + &draft_seed.document, )?; - let message_id = locality_message_id(request.push_id, &operation_id); - if let Some(sent) = find_sent_message_by_message_id(self.api.as_ref(), &message_id)? { + if mutation.move_to_outbox { + let sent = self + .api + .send_draft(GmailDraftSendRequest { + id: mutation.draft_id.clone(), + }) + .map_err(|error| { + LocalityError::Io(format!( + "gmail draft send ambiguous after draft update: {error}" + )) + })?; let sent_id = RemoteId::new(sent.id); changed_remote_ids.push(sent_id.clone()); + let operation_id = mutation.operation_id.clone().ok_or_else(|| { + LocalityError::InvalidState("missing gmail draft send operation id".to_string()) + })?; + effects.push(JournalApplyEffect::ArchivedEntity { + operation_id: operation_id.clone(), + operation_index: mutation.operation_index, + entity_id: mutation.draft_remote_id.clone(), + }); effects.push(JournalApplyEffect::CreatedEntity { operation_id, - operation_index: index, + operation_index: mutation.operation_index, parent_id: RemoteId::new(SENT_FOLDER_ID), entity_id: sent_id, }); - continue; - } - - let draft = draft_from_push_create(title, properties, body)?; - let mime = build_draft_mime_with_message_id(&draft, Some(&message_id))?; - let raw = raw_message_base64url(&mime); - match outbound_target { - OutboundTarget::Draft => { - let created = self.api.create_draft(GmailDraftCreateRequest { - message: GmailRawMessage { raw }, - })?; - let draft_message_id = RemoteId::new(created.message.id); - changed_remote_ids.push(draft_message_id.clone()); - effects.push(JournalApplyEffect::CreatedEntity { - operation_id, - operation_index: index, - parent_id: RemoteId::new(DRAFT_FOLDER_ID), - entity_id: draft_message_id, - }); - } - OutboundTarget::Send => { - let sent = match self.api.send_message(GmailMessageSendRequest { raw }) { - Ok(sent) => sent, - Err(send_error) => { - match find_sent_message_by_message_id(self.api.as_ref(), &message_id) { - Ok(Some(sent)) => sent, - Ok(None) => return Err(send_error), - Err(lookup_error) => { - return Err(LocalityError::Io(format!( - "gmail send ambiguous after send failure; sent lookup failed: {lookup_error}" - ))); - } - } - } - }; - let sent_id = RemoteId::new(sent.id); - changed_remote_ids.push(sent_id.clone()); - effects.push(JournalApplyEffect::CreatedEntity { - operation_id, - operation_index: index, - parent_id: RemoteId::new(SENT_FOLDER_ID), - entity_id: sent_id, - }); - } + } else { + changed_remote_ids.push(mutation.draft_remote_id); } } @@ -593,6 +745,123 @@ enum OutboundTarget { Send, } +#[derive(Clone, Debug)] +struct DraftApplyMutation { + draft_remote_id: RemoteId, + draft_id: String, + move_to_outbox: bool, + title: Option, + properties: BTreeMap, + body: Option, + operation_index: usize, + operation_id: Option, +} + +fn draft_mutation<'a>( + mutations: &'a mut BTreeMap, + draft_remote_id: &RemoteId, + draft_id: &str, + operation_index: usize, + operation_id: PushOperationId, +) -> &'a mut DraftApplyMutation { + mutations + .entry(draft_id.to_string()) + .or_insert_with(|| DraftApplyMutation { + draft_remote_id: draft_remote_id.clone(), + draft_id: draft_id.to_string(), + move_to_outbox: false, + title: None, + properties: BTreeMap::new(), + body: None, + operation_index, + operation_id: Some(operation_id), + }) +} + +struct DraftApplySeed { + document: GmailDraftDocument, + baseline_title: String, +} + +fn draft_document_from_remote_draft(draft: &GmailDraft) -> LocalityResult { + let bundle = GmailNativeBundle { + mailbox: "draft".to_string(), + draft_id: Some(draft.id.clone()), + message: draft.message.clone(), + }; + let rendered = render_gmail_message(&bundle)?; + if !rendered.attachment_specs.is_empty() { + return Err(LocalityError::Unsupported("gmail attachments")); + } + let remote_id = draft_remote_id(&draft.id); + let document = CanonicalDocument::new( + message_frontmatter_with_entity_id(&bundle, &remote_id), + rendered.document.body, + ); + let draft = parse_gmail_draft_document(&document)?; + let baseline_title = draft.subject.clone(); + Ok(DraftApplySeed { + document: draft, + baseline_title, + }) +} + +fn apply_draft_mutation( + draft: &mut GmailDraftDocument, + mutation: &DraftApplyMutation, + baseline_title: Option<&str>, +) -> LocalityResult<()> { + if draft_properties_have_attachments(&mutation.properties) { + return Err(LocalityError::Unsupported("gmail attachments")); + } + if mutation.properties.contains_key("to") { + draft.to = recipients_property(&mutation.properties, "to"); + } + if mutation.properties.contains_key("cc") { + draft.cc = recipients_property(&mutation.properties, "cc"); + } + if mutation.properties.contains_key("bcc") { + draft.bcc = recipients_property(&mutation.properties, "bcc"); + } + if let Some(subject) = non_empty_string_property(&mutation.properties, "subject") { + draft.subject = subject; + } else if let Some(title) = non_empty_string_property(&mutation.properties, "title") { + draft.subject = title; + } else if let Some(title) = mutation.title.as_ref().filter(|title| { + !title.trim().is_empty() + && (draft.subject.trim().is_empty() + || mutation.properties.contains_key("subject") + || mutation.properties.contains_key("title")) + }) { + draft.subject = title.clone(); + } else if let Some(title) = baseline_title.filter(|title| !title.trim().is_empty()) { + draft.subject = title.to_string(); + } else if mutation.properties.contains_key("subject") + || mutation.properties.contains_key("title") + || mutation.move_to_outbox + { + draft.subject.clear(); + } + if let Some(body) = &mutation.body { + draft.body = body.clone(); + } + Ok(()) +} + +fn update_gmail_draft_from_document( + api: &dyn GmailApi, + draft_id: &str, + draft: &GmailDraftDocument, +) -> LocalityResult { + let raw = raw_message_base64url(&build_draft_mime_with_message_id(draft, None)?); + api.update_draft( + draft_id, + GmailDraftUpdateRequest { + message: GmailRawMessage { raw }, + }, + ) +} + fn folder_specs() -> [FolderSpec; 4] { [ FolderSpec { @@ -789,6 +1058,60 @@ fn list_label_entries( .collect() } +fn list_draft_entries( + api: &dyn GmailApi, + settings: &GmailMountSettings, + mount_id: &MountId, + parent_path: &Path, +) -> LocalityResult> { + let Some(query) = gmail_recent_query(settings) else { + let list = api.list_drafts(GMAIL_PAGE_SIZE, None, None)?; + return draft_refs_to_entries(api, mount_id, parent_path, list.drafts); + }; + + let mut entries = Vec::new(); + let mut page_token: Option = None; + let mut seen_page_tokens = BTreeSet::new(); + loop { + let list = api.list_drafts(GMAIL_PAGE_SIZE, page_token.as_deref(), Some(&query))?; + for draft in list.drafts { + entries.push(draft_ref_to_entry(api, mount_id, parent_path, draft)?); + } + let Some(next) = list.next_page_token else { + break; + }; + if !seen_page_tokens.insert(next.clone()) { + return Err(LocalityError::InvalidState(format!( + "gmail pagination returned repeated page token `{next}` for drafts" + ))); + } + page_token = Some(next); + } + Ok(entries) +} + +fn draft_refs_to_entries( + api: &dyn GmailApi, + mount_id: &MountId, + parent_path: &Path, + drafts: Vec, +) -> LocalityResult> { + drafts + .into_iter() + .map(|draft| draft_ref_to_entry(api, mount_id, parent_path, draft)) + .collect() +} + +fn draft_ref_to_entry( + api: &dyn GmailApi, + mount_id: &MountId, + parent_path: &Path, + draft: crate::dto::GmailDraftRef, +) -> LocalityResult { + let draft = api.get_draft_full(&draft.id)?; + draft_entry(mount_id, parent_path, draft.id, draft.message) +} + fn list_thread_entries( api: &dyn GmailApi, settings: &GmailMountSettings, @@ -848,6 +1171,14 @@ fn list_message_refs( Ok(messages) } +fn gmail_recent_query(settings: &GmailMountSettings) -> Option { + settings + .gmail + .date_window + .as_ref() + .map(|window| window.query()) +} + fn list_thread_refs( api: &dyn GmailApi, settings: &GmailMountSettings, @@ -899,6 +1230,7 @@ fn message_entry( let path = parent_path.join(message_filename(&message, &title)); let bundle = GmailNativeBundle { mailbox: mailbox.to_string(), + draft_id: None, message: message.clone(), }; TreeEntry { @@ -914,6 +1246,35 @@ fn message_entry( } } +fn draft_entry( + mount_id: &MountId, + parent_path: &Path, + draft_id: String, + message: GmailMessage, +) -> LocalityResult { + let title = message_subject(&message); + let version = remote_version(&message); + let path = parent_path.join(message_filename(&message, &title)); + let remote_id = draft_remote_id(&draft_id); + let bundle = GmailNativeBundle { + mailbox: "draft".to_string(), + draft_id: Some(draft_id), + message, + }; + let stub_frontmatter = Some(message_frontmatter_with_entity_id(&bundle, &remote_id)); + Ok(TreeEntry { + mount_id: mount_id.clone(), + remote_id, + kind: EntityKind::Page, + title, + path, + hydration: HydrationState::Stub, + content_hash: None, + remote_edited_at: Some(version), + stub_frontmatter, + }) +} + fn thread_message_entry( mount_id: &MountId, parent_path: &Path, @@ -1187,12 +1548,27 @@ fn parse_gmail_draft_document(document: &CanonicalDocument) -> LocalityResult bool { - frontmatter.attachment.is_some() - || frontmatter.attachments.is_some() - || frontmatter - .gmail - .as_ref() - .is_some_and(|gmail| gmail.attachment.is_some() || gmail.attachments.is_some()) + raw_attachment_value_has_metadata(frontmatter.attachment.as_ref()) + || raw_attachment_value_has_metadata(frontmatter.attachments.as_ref()) + || frontmatter.gmail.as_ref().is_some_and(|gmail| { + raw_attachment_value_has_metadata(gmail.attachment.as_ref()) + || raw_attachment_value_has_metadata(gmail.attachments.as_ref()) + }) +} + +fn raw_attachment_value_has_metadata(value: Option<&yaml_serde::Value>) -> bool { + match value { + None | Some(yaml_serde::Value::Null) => false, + Some(yaml_serde::Value::String(value)) => !value.trim().is_empty(), + Some(yaml_serde::Value::Sequence(values)) => values + .iter() + .any(|value| raw_attachment_value_has_metadata(Some(value))), + Some(yaml_serde::Value::Mapping(values)) => !values.is_empty(), + Some(yaml_serde::Value::Tagged(tagged)) => { + raw_attachment_value_has_metadata(Some(&tagged.value)) + } + Some(yaml_serde::Value::Bool(_) | yaml_serde::Value::Number(_)) => true, + } } fn raw_recipients(value: RawRecipients) -> Vec { @@ -1222,15 +1598,29 @@ fn draft_from_push_create( } fn draft_properties_have_attachments(properties: &BTreeMap) -> bool { - properties.contains_key("attachments") - || properties.contains_key("attachment") + property_value_has_attachment_metadata(properties.get("attachments")) + || property_value_has_attachment_metadata(properties.get("attachment")) || matches!( properties.get("gmail"), Some(PropertyValue::Object(gmail)) - if gmail.contains_key("attachments") || gmail.contains_key("attachment") + if property_value_has_attachment_metadata(gmail.get("attachments")) + || property_value_has_attachment_metadata(gmail.get("attachment")) ) } +fn property_value_has_attachment_metadata(value: Option<&PropertyValue>) -> bool { + match value { + None | Some(PropertyValue::Null) => false, + Some(PropertyValue::String(value)) => !value.trim().is_empty(), + Some(PropertyValue::List(values)) => values.iter().any(|value| !value.trim().is_empty()), + Some(PropertyValue::Array(values)) => values + .iter() + .any(|value| property_value_has_attachment_metadata(Some(value))), + Some(PropertyValue::Object(values)) => !values.is_empty(), + Some(PropertyValue::Bool(_) | PropertyValue::Number(_)) => true, + } +} + fn recipients_property(properties: &BTreeMap, key: &str) -> Vec { match properties.get(key) { Some(PropertyValue::List(values)) => values.clone(), @@ -1246,6 +1636,13 @@ fn string_property(properties: &BTreeMap, key: &str) -> O } } +fn non_empty_string_property( + properties: &BTreeMap, + key: &str, +) -> Option { + string_property(properties, key).filter(|value| !value.trim().is_empty()) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct DraftNative { to: Vec, @@ -1278,7 +1675,7 @@ mod tests { ObserveRequest, }; use locality_core::LocalityError; - use locality_core::journal::{PushId, PushOperationId}; + use locality_core::journal::{JournalApplyEffect, PushId, PushOperationId}; use locality_core::model::{CanonicalDocument, EntityKind, MountId, RemoteId}; use locality_core::planner::{PropertyValue, PushOperation, PushPlan}; use locality_core::push::RemotePrecondition; @@ -1287,9 +1684,9 @@ mod tests { use super::{GmailConfig, GmailConnector}; use crate::client::GmailApi; use crate::dto::{ - GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList, - GmailMessagePartBody, GmailMessageRef, GmailMessageSendRequest, GmailThread, - GmailThreadList, + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailDraftRef, GmailDraftSendRequest, + GmailDraftUpdateRequest, GmailMessage, GmailMessageList, GmailMessagePartBody, + GmailMessageRef, GmailMessageSendRequest, GmailThread, GmailThreadList, }; use crate::settings::GmailMountSettings; @@ -1328,6 +1725,9 @@ mod tests { assert!(entries.iter().any(|entry| entry.path.starts_with("inbox/"))); assert!(entries.iter().any(|entry| entry.path.starts_with("sent/"))); assert!(entries.iter().any(|entry| entry.path.starts_with("draft/"))); + assert!(entries.iter().any(|entry| entry.remote_id + == RemoteId::new("gmail-draft:draft-1") + && entry.path.starts_with("draft/"))); assert!( !entries .iter() @@ -1335,7 +1735,8 @@ mod tests { && entry.path.starts_with("outbox")) ); let calls = api.calls.lock().expect("calls"); - assert_eq!(calls.list_max_results, vec![100, 100, 100]); + assert_eq!(calls.list_max_results, vec![100, 100]); + assert_eq!(calls.draft_list_max_results, vec![100]); } #[test] @@ -1398,13 +1799,17 @@ mod tests { "after:2026/07/01 before:2026/07/15".to_string(), "after:2026/07/01 before:2026/07/15".to_string(), "after:2026/07/01 before:2026/07/15".to_string(), - "after:2026/07/01 before:2026/07/15".to_string(), ] ); + assert_eq!( + calls.draft_list_queries, + vec!["after:2026/07/01 before:2026/07/15".to_string()] + ); assert_eq!( calls.list_page_tokens, - vec![None, Some("next-inbox".to_string()), None, None] + vec![None, Some("next-inbox".to_string()), None] ); + assert_eq!(calls.draft_list_page_tokens, vec![None]); } #[test] @@ -1420,9 +1825,64 @@ mod tests { .expect("enumerate"); let calls = api.calls.lock().expect("calls"); - assert_eq!(calls.list_max_results, vec![100, 100, 100]); - assert_eq!(calls.list_page_tokens, vec![None, None, None]); + assert_eq!(calls.list_max_results, vec![100, 100]); + assert_eq!(calls.draft_list_max_results, vec![100]); + assert_eq!(calls.list_page_tokens, vec![None, None]); + assert_eq!(calls.draft_list_page_tokens, vec![None]); assert!(calls.list_queries.is_empty()); + assert!(calls.draft_list_queries.is_empty()); + } + + #[test] + fn enumerate_without_date_window_reads_only_first_draft_page() { + let api = Arc::new(FakeGmailApi::default()); + { + let mut calls = api.calls.lock().expect("calls"); + calls.paged_drafts.insert( + None, + GmailDraftList { + drafts: vec![GmailDraftRef { + id: "draft-1".to_string(), + message: message_fixture("draft-msg-1"), + }], + next_page_token: Some("next-draft".to_string()), + result_size_estimate: Some(2), + }, + ); + calls.paged_drafts.insert( + Some("next-draft".to_string()), + GmailDraftList { + drafts: vec![GmailDraftRef { + id: "draft-2".to_string(), + message: message_fixture("draft-msg-2"), + }], + next_page_token: None, + result_size_estimate: Some(2), + }, + ); + } + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + + let entries = connector + .enumerate(EnumerateRequest { + mount_id: MountId::new("gmail-main"), + cursor: None, + }) + .expect("enumerate"); + + assert!( + entries + .iter() + .any(|entry| entry.remote_id == RemoteId::new("gmail-draft:draft-1")) + ); + assert!( + !entries + .iter() + .any(|entry| entry.remote_id == RemoteId::new("gmail-draft:draft-2")) + ); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_list_page_tokens, vec![None]); + assert!(calls.draft_list_queries.is_empty()); } #[test] @@ -1473,6 +1933,59 @@ mod tests { assert!(message.contains("same-token")); } + #[test] + fn enumerate_with_date_window_rejects_repeated_draft_page_token() { + let api = Arc::new(FakeGmailApi::default()); + { + let mut calls = api.calls.lock().expect("calls"); + calls.panic_after_draft_list_calls = Some(2); + calls.paged_drafts.insert( + None, + GmailDraftList { + drafts: vec![GmailDraftRef { + id: "draft-1".to_string(), + message: message_fixture("draft-msg-1"), + }], + next_page_token: Some("same-draft-token".to_string()), + result_size_estimate: Some(2), + }, + ); + calls.paged_drafts.insert( + Some("same-draft-token".to_string()), + GmailDraftList { + drafts: vec![GmailDraftRef { + id: "draft-2".to_string(), + message: message_fixture("draft-msg-2"), + }], + next_page_token: Some("same-draft-token".to_string()), + result_size_estimate: Some(2), + }, + ); + } + let settings = + GmailMountSettings::with_date_window("2026-07-01", "2026-07-15").expect("settings"); + let connector = GmailConnector::with_api( + GmailConfig::new("token").with_settings(settings), + api.clone(), + ); + + let error = connector + .enumerate(EnumerateRequest { + mount_id: MountId::new("gmail-main"), + cursor: None, + }) + .expect_err("repeated draft page token should fail"); + + let message = error.to_string(); + assert!(message.contains("repeated page token")); + assert!(message.contains("same-draft-token")); + let calls = api.calls.lock().expect("calls"); + assert_eq!( + calls.draft_list_page_tokens, + vec![None, Some("same-draft-token".to_string())] + ); + } + #[test] fn enumerate_projects_threads_when_thread_view_enabled() { let api = Arc::new(FakeGmailApi::default()); @@ -1502,12 +2015,47 @@ mod tests { .iter() .any(|entry| entry.remote_id == RemoteId::new("gmail-thread:sent:thread-sent-1")) ); + assert!(entries.iter().any(|entry| entry.remote_id + == RemoteId::new("gmail-draft:draft-1") + && entry.path.starts_with("draft/"))); + let calls = api.calls.lock().expect("calls"); + assert!(!calls.message_list_labels.contains(&"DRAFT".to_string())); + assert_eq!(calls.draft_list_max_results, vec![100]); } #[test] fn list_children_for_draft_folder_returns_remote_drafts() { let api = Arc::new(FakeGmailApi::default()); - let connector = GmailConnector::with_api(GmailConfig::new("token"), api); + { + let mut calls = api.calls.lock().expect("calls"); + calls.paged_drafts.insert( + None, + GmailDraftList { + drafts: vec![GmailDraftRef { + id: "draft-1".to_string(), + message: GmailMessage { + id: "draft-msg-1".to_string(), + thread_id: Some("draft-msg-1-thread".to_string()), + label_ids: vec!["DRAFT".to_string()], + snippet: None, + internal_date: None, + payload: None, + raw: None, + }, + }], + next_page_token: None, + result_size_estimate: Some(1), + }, + ); + calls.draft_full.insert( + "draft-1".to_string(), + GmailDraft { + id: "draft-1".to_string(), + message: message_fixture("draft-msg-1"), + }, + ); + } + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); let result = connector .list_children(ListChildrenRequest { @@ -1518,7 +2066,19 @@ mod tests { .expect("list draft"); assert_eq!(result.entries.len(), 1); - assert!(result.entries[0].path.starts_with("draft/")); + assert_eq!( + result.entries[0].remote_id, + RemoteId::new("gmail-draft:draft-1") + ); + assert_eq!(result.entries[0].title, "Hello"); + assert_eq!( + result.entries[0].path, + std::path::PathBuf::from("draft/1720900000000-hello-draft-msg-1.md") + ); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_list_max_results, vec![100]); + assert_eq!(calls.draft_full_ids, vec!["draft-1".to_string()]); + assert!(!calls.message_list_labels.contains(&"DRAFT".to_string())); } #[test] @@ -1656,6 +2216,50 @@ mod tests { assert!(rendered.frontmatter.contains("message_id: \"inbox-msg-1\"")); } + #[test] + fn fetch_remote_draft_uses_draft_resource_and_renders_draft_id() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let remote_id = RemoteId::new("gmail-draft:draft-1"); + + let native = connector + .fetch(FetchRequest { + remote_id: remote_id.clone(), + }) + .expect("fetch draft"); + + assert_eq!(native.remote_id, remote_id); + assert_eq!(native.kind, "gmail_message"); + let rendered = connector.render(&native).expect("render draft"); + assert!(rendered.frontmatter.contains("id: \"gmail-draft:draft-1\"")); + assert!(rendered.frontmatter.contains("mailbox: \"draft\"")); + assert!(rendered.frontmatter.contains("draft_id: \"draft-1\"")); + assert!(rendered.frontmatter.contains("message_id: \"draft-msg-1\"")); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_full_ids, vec!["draft-1".to_string()]); + assert!(calls.message_full_ids.is_empty()); + } + + #[test] + fn fetch_legacy_draft_message_remote_id_still_works() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + + let native = connector + .fetch(FetchRequest { + remote_id: RemoteId::new("draft-msg-1"), + }) + .expect("fetch legacy draft message"); + + let rendered = connector.render(&native).expect("render legacy draft"); + assert!(rendered.frontmatter.contains("id: \"draft-msg-1\"")); + assert!(rendered.frontmatter.contains("mailbox: \"draft\"")); + assert!(!rendered.frontmatter.contains("draft_id:")); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.message_full_ids, vec!["draft-msg-1".to_string()]); + assert!(calls.draft_full_ids.is_empty()); + } + #[test] fn observe_thread_remote_id_returns_thread_page_metadata() { let api = Arc::new(FakeGmailApi::default()); @@ -1697,6 +2301,60 @@ mod tests { assert!(search_terms.contains(&serde_json::json!("Hello"))); } + #[test] + fn observe_remote_draft_uses_draft_resource() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let remote_id = RemoteId::new("gmail-draft:draft-1"); + + let observation = connector + .observe(ObserveRequest { + mount_id: MountId::new("gmail-main"), + remote_id: remote_id.clone(), + }) + .expect("observe draft"); + + assert_eq!(observation.remote_id, remote_id); + assert_eq!( + observation.parent_remote_id, + Some(RemoteId::new("gmail-folder:draft")) + ); + assert_eq!( + observation.projected_path, + std::path::PathBuf::from("draft/1720900000000-hello-draft-msg-1.md") + ); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_full_ids, vec!["draft-1".to_string()]); + assert!(calls.message_metadata_ids.is_empty()); + } + + #[test] + fn observe_legacy_draft_message_remote_id_still_works() { + let api = Arc::new(FakeGmailApi::default()); + api.calls + .lock() + .expect("calls") + .message_labels + .insert("draft-msg-1".to_string(), vec!["DRAFT".to_string()]); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + + let observation = connector + .observe(ObserveRequest { + mount_id: MountId::new("gmail-main"), + remote_id: RemoteId::new("draft-msg-1"), + }) + .expect("observe legacy draft"); + + assert_eq!( + observation.parent_remote_id, + Some(RemoteId::new("gmail-folder:draft")) + ); + assert_eq!(observation.remote_id, RemoteId::new("draft-msg-1")); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.message_metadata_ids, vec!["draft-msg-1".to_string()]); + assert!(calls.draft_full_ids.is_empty()); + } + #[test] fn list_children_for_root_uses_receiving_parent_path() { let api = Arc::new(FakeGmailApi::default()); @@ -1944,7 +2602,16 @@ mod tests { assert_eq!( result.changed_remote_ids, - vec![RemoteId::new("draft-message-1")] + vec![RemoteId::new("gmail-draft:draft-1")] + ); + assert_eq!( + result.effects, + vec![JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("op-1".to_string()), + operation_index: 0, + parent_id: RemoteId::new("gmail-folder:draft"), + entity_id: RemoteId::new("gmail-draft:draft-1"), + }] ); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 1); @@ -2295,7 +2962,7 @@ mod tests { assert_eq!( result.changed_remote_ids, - vec![RemoteId::new("draft-message-1")] + vec![RemoteId::new("gmail-draft:draft-1")] ); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 1); @@ -2354,7 +3021,7 @@ mod tests { assert_eq!( result.changed_remote_ids, - vec![RemoteId::new("draft-message-1")] + vec![RemoteId::new("gmail-draft:draft-1")] ); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 1); @@ -2457,6 +3124,576 @@ mod tests { assert_eq!(calls.sent_messages, 0); } + #[test] + fn apply_updates_remote_gmail_draft() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![ + PushOperation::UpdateProperties { + entity_id: draft_remote_id.clone(), + properties: std::collections::BTreeMap::from([ + ( + "to".to_string(), + PropertyValue::List(vec!["ann@example.com".to_string()]), + ), + ( + "subject".to_string(), + PropertyValue::String("Updated subject".to_string()), + ), + ]), + }, + PushOperation::UpdateEntityBody { + entity_id: draft_remote_id.clone(), + body: "Updated body\nSecond line\n".to_string(), + }, + ], + ); + + let result = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[ + PushOperationId("op-properties".to_string()), + PushOperationId("op-body".to_string()), + ], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + assert_eq!(result.changed_remote_ids, vec![draft_remote_id]); + assert!(result.effects.is_empty()); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_full_ids, vec!["draft-123"]); + assert_eq!(calls.updated_drafts.len(), 1); + assert_eq!(calls.updated_drafts[0].0, "draft-123"); + assert!(calls.sent_drafts.is_empty()); + assert_eq!(calls.sent_messages, 0); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("To: ann@example.com\r\n")); + assert!(mime.contains("Subject: Updated subject\r\n")); + assert!(!mime.contains("Message-ID: <")); + assert!(mime.contains("\r\n\r\nUpdated body\r\nSecond line\r\n")); + } + + #[test] + fn apply_updates_remote_gmail_draft_preserves_bcc_on_body_only_update() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture_with_header( + "draft-original-msg", + "Bcc", + "hidden@example.com", + ), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![PushOperation::UpdateEntityBody { + entity_id: draft_remote_id, + body: "Body-only update\n".to_string(), + }], + ); + + connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-body".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + let calls = api.calls.lock().expect("calls"); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("Bcc: hidden@example.com\r\n")); + assert!(mime.contains("\r\n\r\nBody-only update\r\n")); + } + + #[test] + fn apply_updates_remote_gmail_draft_uses_title_fallback_when_subject_null() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![PushOperation::UpdateProperties { + entity_id: draft_remote_id, + properties: std::collections::BTreeMap::from([ + ("subject".to_string(), PropertyValue::Null), + ( + "title".to_string(), + PropertyValue::String("Title fallback".to_string()), + ), + ]), + }], + ); + + connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-properties".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + let calls = api.calls.lock().expect("calls"); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("Subject: Title fallback\r\n")); + } + + #[test] + fn apply_updates_remote_gmail_draft_uses_current_title_when_subject_null_without_title_delta() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![PushOperation::UpdateProperties { + entity_id: draft_remote_id, + properties: std::collections::BTreeMap::from([( + "subject".to_string(), + PropertyValue::Null, + )]), + }], + ); + + connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-properties".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + let calls = api.calls.lock().expect("calls"); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("Subject: Hello\r\n")); + } + + #[test] + fn apply_sends_remote_gmail_draft_moved_to_outbox() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![ + PushOperation::MoveEntity { + entity_id: draft_remote_id.clone(), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Move title subject".to_string(), + projected_path: "outbox/move-title-subject.md".into(), + }, + PushOperation::UpdateProperties { + entity_id: draft_remote_id.clone(), + properties: std::collections::BTreeMap::from([( + "to".to_string(), + PropertyValue::List(vec!["ann@example.com".to_string()]), + )]), + }, + PushOperation::UpdateEntityBody { + entity_id: draft_remote_id.clone(), + body: "Ready to send\n".to_string(), + }, + ], + ); + + let result = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[ + PushOperationId("op-move".to_string()), + PushOperationId("op-properties".to_string()), + PushOperationId("op-body".to_string()), + ], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + assert_eq!(result.changed_remote_ids, vec![RemoteId::new("sent-msg-1")]); + assert!(matches!( + result.effects.as_slice(), + [ + locality_core::journal::JournalApplyEffect::ArchivedEntity { + operation_id: archived_operation_id, + operation_index: 0, + entity_id: archived_entity_id, + }, + locality_core::journal::JournalApplyEffect::CreatedEntity { + operation_id: created_operation_id, + operation_index: 0, + parent_id, + entity_id: created_entity_id, + } + ] if archived_operation_id == &PushOperationId("op-move".to_string()) + && archived_entity_id == &draft_remote_id + && created_operation_id == &PushOperationId("op-move".to_string()) + && parent_id == &RemoteId::new("gmail-folder:sent") + && created_entity_id == &RemoteId::new("sent-msg-1") + )); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_full_ids, vec!["draft-123"]); + assert_eq!(calls.updated_drafts.len(), 1); + assert_eq!(calls.updated_drafts[0].0, "draft-123"); + assert_eq!(calls.sent_drafts, vec!["draft-123"]); + assert_eq!( + calls.call_log, + vec![ + "get_draft_full:draft-123".to_string(), + "update_draft:draft-123".to_string(), + "send_draft:draft-123".to_string(), + ] + ); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("To: ann@example.com\r\n")); + assert!(mime.contains("Subject: Hello\r\n")); + assert!(!mime.contains("Subject: Move title subject\r\n")); + assert!(!mime.contains("Message-ID: <")); + assert!(mime.contains("\r\n\r\nReady to send\r\n")); + } + + #[test] + fn apply_sends_remote_gmail_draft_preserves_current_subject_on_move_only_send() { + let api = Arc::new(FakeGmailApi::default()); + let mut message = message_fixture("draft-original-msg"); + for header in &mut message.payload.as_mut().expect("payload").headers { + if header.name.eq_ignore_ascii_case("subject") { + header.value = "Updated draft subject".to_string(); + } + } + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message, + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![PushOperation::MoveEntity { + entity_id: draft_remote_id, + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Original projected title".to_string(), + projected_path: "outbox/original-projected-title.md".into(), + }], + ); + + connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-move".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + let calls = api.calls.lock().expect("calls"); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("Subject: Updated draft subject\r\n")); + assert!(!mime.contains("Subject: Original projected title\r\n")); + assert_eq!(calls.sent_drafts, vec!["draft-123"]); + } + + #[test] + fn apply_sends_remote_gmail_draft_uses_move_title_when_subject_blank() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let draft_remote_id = RemoteId::new("gmail-draft:draft-123"); + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![ + PushOperation::MoveEntity { + entity_id: draft_remote_id.clone(), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Move title fallback".to_string(), + projected_path: "outbox/move-title-fallback.md".into(), + }, + PushOperation::UpdateProperties { + entity_id: draft_remote_id, + properties: std::collections::BTreeMap::from([( + "subject".to_string(), + PropertyValue::String(String::new()), + )]), + }, + ], + ); + + connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[ + PushOperationId("op-move".to_string()), + PushOperationId("op-properties".to_string()), + ], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("apply"); + + let calls = api.calls.lock().expect("calls"); + let mime = decode_raw_mime(&calls.updated_drafts[0].1); + assert!(mime.contains("Subject: Move title fallback\r\n")); + assert_eq!(calls.sent_drafts, vec!["draft-123"]); + } + + #[test] + fn apply_rejects_move_of_non_draft_gmail_entity() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("sent-msg-1")], + vec![PushOperation::MoveEntity { + entity_id: RemoteId::new("sent-msg-1"), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send again".to_string(), + projected_path: "outbox/send-again.md".into(), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-move".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect_err("non-draft Gmail moves should be unsupported"); + + assert!(matches!(error, LocalityError::Unsupported(_))); + let calls = api.calls.lock().expect("calls"); + assert!(calls.updated_drafts.is_empty()); + assert!(calls.sent_drafts.is_empty()); + } + + #[test] + fn apply_rejects_gmail_draft_move_to_non_outbox_parent() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("gmail-draft:draft-123")], + vec![PushOperation::MoveEntity { + entity_id: RemoteId::new("gmail-draft:draft-123"), + new_parent_id: RemoteId::new("gmail-folder:sent"), + new_parent_kind: EntityKind::Directory, + new_title: "Wrong parent".to_string(), + projected_path: "sent/wrong-parent.md".into(), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-move".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect_err("draft move to non-outbox parent should be unsupported"); + + assert!(matches!(error, LocalityError::Unsupported(_))); + let calls = api.calls.lock().expect("calls"); + assert!(calls.updated_drafts.is_empty()); + assert!(calls.sent_drafts.is_empty()); + } + + #[test] + fn apply_rejects_draft_update_with_attachments() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("gmail-draft:draft-123")], + vec![PushOperation::UpdateProperties { + entity_id: RemoteId::new("gmail-draft:draft-123"), + properties: std::collections::BTreeMap::from([( + "gmail".to_string(), + PropertyValue::Object(std::collections::BTreeMap::from([( + "attachments".to_string(), + PropertyValue::List(vec!["invoice.pdf".to_string()]), + )])), + )]), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-properties".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect_err("draft updates with attachments should be unsupported"); + + assert!(matches!(error, LocalityError::Unsupported(_))); + let calls = api.calls.lock().expect("calls"); + assert!(calls.updated_drafts.is_empty()); + assert!(calls.sent_drafts.is_empty()); + } + + #[test] + fn apply_allows_empty_draft_attachment_metadata() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("gmail-draft:draft-123")], + vec![PushOperation::UpdateProperties { + entity_id: RemoteId::new("gmail-draft:draft-123"), + properties: std::collections::BTreeMap::from([ + ( + "attachment".to_string(), + PropertyValue::String(String::new()), + ), + ("attachments".to_string(), PropertyValue::List(Vec::new())), + ( + "gmail".to_string(), + PropertyValue::Object(std::collections::BTreeMap::from([( + "attachments".to_string(), + PropertyValue::Array(Vec::new()), + )])), + ), + ]), + }], + ); + + connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-properties".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect("empty attachment metadata should be ignored"); + + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_full_ids, vec!["draft-123"]); + assert_eq!(calls.updated_drafts.len(), 1); + assert!(calls.sent_drafts.is_empty()); + } + + #[test] + fn apply_rejects_draft_update_when_remote_draft_has_attachments() { + let api = Arc::new(FakeGmailApi::default()); + api.calls.lock().expect("calls").draft_full.insert( + "draft-123".to_string(), + GmailDraft { + id: "draft-123".to_string(), + message: message_fixture_with_attachment("draft-original-msg"), + }, + ); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let plan = PushPlan::new( + vec![RemoteId::new("gmail-draft:draft-123")], + vec![PushOperation::UpdateEntityBody { + entity_id: RemoteId::new("gmail-draft:draft-123"), + body: "Edited body\n".to_string(), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-1".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-body".to_string())], + remote_preconditions: &[] as &[RemotePrecondition], + local_root: None, + }) + .expect_err("remote draft attachments should be unsupported"); + + assert!(matches!(error, LocalityError::Unsupported(_))); + let calls = api.calls.lock().expect("calls"); + assert_eq!(calls.draft_full_ids, vec!["draft-123"]); + assert!(calls.updated_drafts.is_empty()); + assert!(calls.sent_drafts.is_empty()); + } + #[test] fn parse_draft_rejects_nested_gmail_attachment_metadata() { let api = Arc::new(FakeGmailApi::default()); @@ -2472,6 +3709,19 @@ mod tests { assert!(matches!(error, LocalityError::Unsupported(_))); } + #[test] + fn parse_draft_allows_empty_attachment_metadata() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api); + + connector + .parse(&CanonicalDocument::new( + "to: [\"ann@example.com\"]\nsubject: Hello\nattachment: \"\"\nattachments: []\ngmail:\n attachments: []\n", + "Body", + )) + .expect("empty attachment metadata should be ignored"); + } + #[test] fn parse_invalid_draft_frontmatter_returns_validation_error() { let api = Arc::new(FakeGmailApi::default()); @@ -2505,6 +3755,7 @@ mod tests { #[derive(Default, Debug)] struct FakeCalls { + message_list_labels: Vec, list_max_results: Vec, list_queries: Vec, paged_message_ids: std::collections::BTreeMap<(String, Option), GmailMessageList>, @@ -2512,6 +3763,12 @@ mod tests { paged_thread_ids: std::collections::BTreeMap<(String, Option), GmailThreadList>, thread_metadata: std::collections::BTreeMap, list_page_tokens: Vec>, + draft_list_max_results: Vec, + draft_list_page_tokens: Vec>, + draft_list_queries: Vec, + paged_drafts: std::collections::BTreeMap, GmailDraftList>, + draft_full: std::collections::BTreeMap, + panic_after_draft_list_calls: Option, panic_after_list_calls: Option, sent_search_results: std::collections::BTreeMap, sent_search_results_after_send: std::collections::BTreeMap, @@ -2519,8 +3776,13 @@ mod tests { send_message_error: Option, sent_search_error_after_send: Option, message_labels: std::collections::BTreeMap>, + message_metadata_ids: Vec, + message_full_ids: Vec, + draft_full_ids: Vec, + call_log: Vec, created_drafts: usize, created_draft_raw: Vec, + updated_drafts: Vec<(String, String)>, sent_drafts: Vec, sent_messages: usize, sent_message_raw: Vec, @@ -2535,6 +3797,7 @@ mod tests { query: Option<&str>, ) -> locality_core::LocalityResult { let mut calls = self.calls.lock().expect("calls"); + calls.message_list_labels.push(label_id.to_string()); calls.list_max_results.push(max_results); calls.list_page_tokens.push(_page_token.map(str::to_string)); if let Some(limit) = calls.panic_after_list_calls { @@ -2651,13 +3914,9 @@ mod tests { &self, message_id: &str, ) -> locality_core::LocalityResult { - let labels = self - .calls - .lock() - .expect("calls") - .message_labels - .get(message_id) - .cloned(); + let mut calls = self.calls.lock().expect("calls"); + calls.message_metadata_ids.push(message_id.to_string()); + let labels = calls.message_labels.get(message_id).cloned(); Ok(message_fixture_with_labels(message_id, labels)) } @@ -2665,6 +3924,11 @@ mod tests { &self, message_id: &str, ) -> locality_core::LocalityResult { + self.calls + .lock() + .expect("calls") + .message_full_ids + .push(message_id.to_string()); Ok(message_fixture(message_id)) } @@ -2697,6 +3961,56 @@ mod tests { Ok(GmailMessagePartBody::default()) } + fn list_drafts( + &self, + max_results: u32, + page_token: Option<&str>, + query: Option<&str>, + ) -> locality_core::LocalityResult { + let mut calls = self.calls.lock().expect("calls"); + calls.draft_list_max_results.push(max_results); + calls + .draft_list_page_tokens + .push(page_token.map(str::to_string)); + if let Some(limit) = calls.panic_after_draft_list_calls { + assert!( + calls.draft_list_max_results.len() <= limit, + "list_drafts exceeded call limit {limit}" + ); + } + if let Some(query) = query { + calls.draft_list_queries.push(query.to_string()); + } + if let Some(page) = calls + .paged_drafts + .get(&page_token.map(str::to_string)) + .cloned() + { + return Ok(page); + } + Ok(GmailDraftList { + drafts: vec![GmailDraftRef { + id: "draft-1".to_string(), + message: message_fixture("draft-msg-1"), + }], + next_page_token: None, + result_size_estimate: Some(1), + }) + } + + fn get_draft_full(&self, draft_id: &str) -> locality_core::LocalityResult { + let mut calls = self.calls.lock().expect("calls"); + calls.draft_full_ids.push(draft_id.to_string()); + calls.call_log.push(format!("get_draft_full:{draft_id}")); + if let Some(draft) = calls.draft_full.get(draft_id).cloned() { + return Ok(draft); + } + Ok(GmailDraft { + id: draft_id.to_string(), + message: message_fixture("draft-msg-1"), + }) + } + fn create_draft( &self, request: GmailDraftCreateRequest, @@ -2710,6 +4024,22 @@ mod tests { }) } + fn update_draft( + &self, + draft_id: &str, + request: GmailDraftUpdateRequest, + ) -> locality_core::LocalityResult { + let mut calls = self.calls.lock().expect("calls"); + calls.call_log.push(format!("update_draft:{draft_id}")); + calls + .updated_drafts + .push((draft_id.to_string(), request.message.raw)); + Ok(GmailDraft { + id: draft_id.to_string(), + message: message_fixture(&format!("updated-draft-message-{draft_id}")), + }) + } + fn send_message( &self, request: GmailMessageSendRequest, @@ -2728,6 +4058,7 @@ mod tests { request: GmailDraftSendRequest, ) -> locality_core::LocalityResult { let mut calls = self.calls.lock().expect("calls"); + calls.call_log.push(format!("send_draft:{}", request.id)); calls.sent_drafts.push(request.id); if let Some(error) = calls.send_error.clone() { return Err(error); @@ -2747,6 +4078,48 @@ mod tests { message_fixture_with_labels(id, labels) } + fn decode_raw_mime(raw: &str) -> String { + String::from_utf8( + URL_SAFE_NO_PAD + .decode(raw.as_bytes()) + .expect("decode raw mime"), + ) + .expect("utf8 mime") + } + + fn message_fixture_with_attachment(id: &str) -> GmailMessage { + serde_json::from_value(serde_json::json!({ + "id": id, + "threadId": format!("{id}-thread"), + "labelIds": ["DRAFT"], + "internalDate": "1720900000000", + "payload": { + "mimeType": "multipart/mixed", + "headers": [ + { "name": "From", "value": "Ann " }, + { "name": "To", "value": "me@example.com" }, + { "name": "Subject", "value": "Hello" }, + { "name": "Date", "value": "Tue, 14 Jul 2026 09:30:00 +0000" } + ], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": "Qm9keQo" } + }, + { + "mimeType": "application/pdf", + "filename": "invoice.pdf", + "body": { + "attachmentId": "attachment-1", + "size": 12 + } + } + ] + } + })) + .expect("message with attachment") + } + fn thread_fixture(thread_id: &str) -> crate::dto::GmailThread { let message_id = if thread_id.contains("sent") { "sent-msg-1" @@ -2805,4 +4178,14 @@ mod tests { })) .expect("message") } + + fn message_fixture_with_header(id: &str, name: &str, value: &str) -> GmailMessage { + let mut message = message_fixture(id); + let payload = message.payload.as_mut().expect("payload"); + payload.headers.push(crate::dto::GmailHeader { + name: name.to_string(), + value: value.to_string(), + }); + message + } } diff --git a/crates/locality-gmail/src/dto.rs b/crates/locality-gmail/src/dto.rs index f711a227..df372e9b 100644 --- a/crates/locality-gmail/src/dto.rs +++ b/crates/locality-gmail/src/dto.rs @@ -20,6 +20,15 @@ pub struct GmailThreadList { pub result_size_estimate: Option, } +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GmailDraftList { + #[serde(default)] + pub drafts: Vec, + pub next_page_token: Option, + pub result_size_estimate: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GmailMessageRef { @@ -27,6 +36,13 @@ pub struct GmailMessageRef { pub thread_id: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GmailDraftRef { + pub id: String, + pub message: GmailMessage, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GmailThreadRef { @@ -89,6 +105,11 @@ pub struct GmailDraftCreateRequest { pub message: GmailRawMessage, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GmailDraftUpdateRequest { + pub message: GmailRawMessage, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GmailMessageSendRequest { pub raw: String, diff --git a/crates/locality-gmail/src/oauth.rs b/crates/locality-gmail/src/oauth.rs index 5716310e..5359f7d4 100644 --- a/crates/locality-gmail/src/oauth.rs +++ b/crates/locality-gmail/src/oauth.rs @@ -243,7 +243,7 @@ impl HttpGmailOAuthBrokerClient { pub fn gmail_capabilities_json() -> Result { let capabilities = ConnectorCapabilities { supports_block_updates: false, - supports_entity_body_updates: false, + supports_entity_body_updates: true, supports_databases: false, supports_oauth: true, supports_remote_observation: true, @@ -352,6 +352,7 @@ mod tests { assert!(capabilities.supports_oauth); assert!(capabilities.supports_remote_observation); assert!(capabilities.supports_lazy_child_enumeration); + assert!(capabilities.supports_entity_body_updates); assert!(!capabilities.supports_databases); assert!(!capabilities.supports_media_download); assert!(!capabilities.supports_block_updates); diff --git a/crates/locality-gmail/src/render.rs b/crates/locality-gmail/src/render.rs index c414d707..951acc47 100644 --- a/crates/locality-gmail/src/render.rs +++ b/crates/locality-gmail/src/render.rs @@ -13,6 +13,8 @@ use crate::oauth::GMAIL_CONNECTOR_ID; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GmailNativeBundle { pub mailbox: String, + #[serde(default)] + pub draft_id: Option, pub message: GmailMessage, } @@ -46,7 +48,7 @@ pub struct GmailDraftDocument { } pub fn render_gmail_message(bundle: &GmailNativeBundle) -> LocalityResult { - render_gmail_message_with_entity_id(bundle, RemoteId::new(bundle.message.id.clone())) + render_gmail_message_with_entity_id(bundle, gmail_bundle_entity_id(bundle)) } fn render_gmail_message_with_entity_id( @@ -87,6 +89,16 @@ pub fn parse_thread_remote_id(remote_id: &RemoteId) -> Option<(&str, &str)> { rest.split_once(':') } +const DRAFT_REMOTE_PREFIX: &str = "gmail-draft:"; + +pub(crate) fn draft_remote_id(draft_id: &str) -> RemoteId { + RemoteId::new(format!("{DRAFT_REMOTE_PREFIX}{draft_id}")) +} + +pub(crate) fn parse_draft_remote_id(remote_id: &RemoteId) -> Option<&str> { + remote_id.as_str().strip_prefix(DRAFT_REMOTE_PREFIX) +} + pub fn thread_message_remote_id(mailbox: &str, thread_id: &str, message_id: &str) -> RemoteId { RemoteId::new(format!( "gmail-thread-message:{mailbox}:{thread_id}:{message_id}" @@ -106,6 +118,7 @@ pub fn render_gmail_thread_message( render_gmail_message_with_entity_id( &GmailNativeBundle { mailbox: bundle.mailbox.clone(), + draft_id: None, message: bundle.message.clone(), }, thread_message_remote_id(&bundle.mailbox, &bundle.thread_id, &bundle.message.id), @@ -183,11 +196,21 @@ fn thread_frontmatter( } pub fn message_frontmatter(bundle: &GmailNativeBundle) -> String { - message_frontmatter_with_attachment_state( - bundle, - None, - &RemoteId::new(bundle.message.id.clone()), - ) + message_frontmatter_with_attachment_state(bundle, None, &gmail_bundle_entity_id(bundle)) +} + +pub(crate) fn message_frontmatter_with_entity_id( + bundle: &GmailNativeBundle, + entity_id: &RemoteId, +) -> String { + message_frontmatter_with_attachment_state(bundle, None, entity_id) +} + +fn gmail_bundle_entity_id(bundle: &GmailNativeBundle) -> RemoteId { + match &bundle.draft_id { + Some(draft_id) => draft_remote_id(draft_id), + None => RemoteId::new(bundle.message.id.clone()), + } } fn message_frontmatter_with_attachment_state( @@ -205,15 +228,21 @@ fn message_frontmatter_with_attachment_state( let attachments = attachment_specs .map(attachment_frontmatter) .unwrap_or_default(); + let draft_id = bundle + .draft_id + .as_ref() + .map(|draft_id| format!(" draft_id: {}\n", yaml_scalar(draft_id))) + .unwrap_or_default(); format!( - "loc:\n id: {}\n type: page\n connector: {}\n synced_at: {}\n remote_edited_at: {}\ntitle: {}\ngmail:\n mailbox: {}\n message_id: {}\n thread_id: {}\n labels: [{}]\n{}from: {}\nto: [{}]\ncc: [{}]\nbcc: []\nsubject: {}\ndate: {}\n", + "loc:\n id: {}\n type: page\n connector: {}\n synced_at: {}\n remote_edited_at: {}\ntitle: {}\ngmail:\n mailbox: {}\n{} message_id: {}\n thread_id: {}\n labels: [{}]\n{}from: {}\nto: [{}]\ncc: [{}]\nbcc: [{}]\nsubject: {}\ndate: {}\n", yaml_scalar(entity_id.as_str()), GMAIL_CONNECTOR_ID, yaml_scalar(&version), yaml_scalar(&version), yaml_scalar(&subject), yaml_scalar(&bundle.mailbox), + draft_id, yaml_scalar(&message.id), yaml_scalar(message.thread_id.as_deref().unwrap_or("")), message @@ -226,6 +255,7 @@ fn message_frontmatter_with_attachment_state( yaml_scalar(headers.get("from").map(String::as_str).unwrap_or("")), yaml_list_items(headers.get("to").map(String::as_str).unwrap_or("")), yaml_list_items(headers.get("cc").map(String::as_str).unwrap_or("")), + yaml_list_items(headers.get("bcc").map(String::as_str).unwrap_or("")), yaml_scalar(&subject), yaml_scalar(headers.get("date").map(String::as_str).unwrap_or("")), ) @@ -629,6 +659,7 @@ mod tests { .expect("message"); let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); @@ -636,6 +667,7 @@ mod tests { assert!(rendered.document.frontmatter.contains("connector: gmail")); assert!(rendered.document.frontmatter.contains("mailbox: \"inbox\"")); assert!(rendered.document.frontmatter.contains("attachments: []")); + assert!(rendered.document.frontmatter.contains("bcc: []")); assert!(rendered.document.frontmatter.contains("subject: \"Hello\"")); assert_eq!(rendered.document.body, "Hello from Gmail.\n"); assert_eq!(rendered.shadow.entity_id.as_str(), "msg-1"); @@ -724,6 +756,7 @@ mod tests { let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); @@ -749,6 +782,7 @@ mod tests { let frontmatter = message_frontmatter(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }); @@ -785,6 +819,7 @@ mod tests { let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); @@ -832,6 +867,7 @@ mod tests { let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); @@ -862,6 +898,7 @@ mod tests { let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); @@ -900,6 +937,7 @@ mod tests { let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); @@ -991,6 +1029,7 @@ mod tests { let rendered = render_gmail_message(&GmailNativeBundle { mailbox: "inbox".to_string(), + draft_id: None, message, }) .expect("render"); diff --git a/crates/locality-store/src/memory.rs b/crates/locality-store/src/memory.rs index cf05b1c4..77d6a911 100644 --- a/crates/locality-store/src/memory.rs +++ b/crates/locality-store/src/memory.rs @@ -1209,6 +1209,11 @@ impl ShadowRepository for InMemoryStateStore { .get(&Self::shadow_key(mount_id, entity_id)) .cloned()) } + + fn delete_shadow(&mut self, mount_id: &MountId, entity_id: &RemoteId) -> StoreResult<()> { + self.shadows.remove(&Self::shadow_key(mount_id, entity_id)); + Ok(()) + } } impl VirtualMutationRepository for InMemoryStateStore { diff --git a/crates/locality-store/src/repository.rs b/crates/locality-store/src/repository.rs index 1b6b8240..e937d9c6 100644 --- a/crates/locality-store/src/repository.rs +++ b/crates/locality-store/src/repository.rs @@ -426,6 +426,7 @@ pub trait ShadowRepository { mount_id: &MountId, entity_id: &RemoteId, ) -> StoreResult>; + fn delete_shadow(&mut self, mount_id: &MountId, entity_id: &RemoteId) -> StoreResult<()>; } pub trait JournalRepository { diff --git a/crates/locality-store/src/sqlite.rs b/crates/locality-store/src/sqlite.rs index 7d81e63a..d6aac4d1 100644 --- a/crates/locality-store/src/sqlite.rs +++ b/crates/locality-store/src/sqlite.rs @@ -4640,6 +4640,16 @@ impl ShadowRepository for SqliteStateStore { .map(shadow_from_row) .transpose() } + + fn delete_shadow(&mut self, mount_id: &MountId, entity_id: &RemoteId) -> StoreResult<()> { + let connection = self.connection()?; + connection.execute( + "DELETE FROM shadows WHERE mount_id = ?1 AND entity_id = ?2", + params![mount_id.0, entity_id.0], + )?; + upsert_entity_search_index(&connection, mount_id, entity_id)?; + Ok(()) + } } impl VirtualMutationRepository for SqliteStateStore { diff --git a/crates/localityd/src/gmail.rs b/crates/localityd/src/gmail.rs index 76f7ef82..83838a9c 100644 --- a/crates/localityd/src/gmail.rs +++ b/crates/localityd/src/gmail.rs @@ -5,8 +5,9 @@ use locality_connector::oauth_broker::OAuthBrokerRefresh; use locality_connector::{Connector, EnumerateRequest, FetchRequest}; use locality_core::diff::property_value_from_frontmatter; use locality_core::hydration::HydrationRequest; -use locality_core::model::{RemoteId, TreeEntry}; +use locality_core::model::{EntityKind, HydrationState, RemoteId, TreeEntry}; use locality_core::planner::PropertyValue; +use locality_core::shadow::ShadowDocument; use locality_core::validation::{ValidationIssue, ValidationReport}; use locality_core::{LocalityError, LocalityResult}; use locality_gmail::attachments::{GmailAttachmentSpec, decode_attachment_body}; @@ -21,7 +22,9 @@ use locality_gmail::{ }; use locality_store::{ ConnectionRecord, ConnectionRepository, ConnectorProfileRepository, CredentialError, - CredentialStore, MountConfig, + CredentialStore, EntityRecord, EntityRepository, FreshnessStateRepository, + HydrationJobRepository, MountConfig, RemoteObservationRepository, ShadowRepository, + StoreResult, }; use crate::hydration::{HydratedAsset, HydratedEntity, HydrationSource}; @@ -29,6 +32,13 @@ use crate::notion::ConnectorResolveError; use crate::source::{SourceAdapter, SourcePushValidator, SourceValidationContext}; const GMAIL_CONNECT_COMMAND: &str = "loc connect gmail"; +const GMAIL_DRAFT_REMOTE_PREFIX: &str = "gmail-draft:"; + +#[derive(Clone, Debug, Default)] +pub(crate) struct GmailDraftIdentityRepair { + pub retired_legacy: Option, + pub retired_shadow: Option, +} pub fn resolve_gmail_connector_for_mount( store: &S, @@ -73,6 +83,79 @@ where }) } +pub(crate) fn repair_legacy_gmail_draft_message_id_collision( + store: &mut S, + mount: &MountConfig, + record: &EntityRecord, + replacement_frontmatter: Option<&str>, +) -> StoreResult +where + S: EntityRepository + + ShadowRepository + + HydrationJobRepository + + FreshnessStateRepository + + RemoteObservationRepository + + ?Sized, +{ + if !is_gmail_draft_identity_repair_candidate(mount, record) { + return Ok(GmailDraftIdentityRepair::default()); + } + + let Some(occupant) = store.find_entity_by_path(&mount.mount_id, &record.path)? else { + return Ok(GmailDraftIdentityRepair::default()); + }; + if occupant.remote_id == record.remote_id { + return Ok(GmailDraftIdentityRepair::default()); + } + if is_clean_legacy_gmail_draft_message_id(&occupant) { + let retired_shadow = store + .get_shadow_record(&mount.mount_id, &occupant.remote_id)? + .map(|record| record.into_document()); + if let Some(mut migrated_shadow) = retired_shadow.clone() { + migrated_shadow.entity_id = record.remote_id.clone(); + if let Some(frontmatter) = replacement_frontmatter { + migrated_shadow.frontmatter = frontmatter.to_string(); + } + store.save_shadow(&mount.mount_id, migrated_shadow)?; + } + store.delete_hydration_job(&mount.mount_id, &occupant.remote_id)?; + store.delete_freshness_state(&mount.mount_id, &occupant.remote_id)?; + store.delete_remote_observation(&mount.mount_id, &occupant.remote_id)?; + store.delete_shadow(&mount.mount_id, &occupant.remote_id)?; + store.delete_entity(&mount.mount_id, &occupant.remote_id)?; + return Ok(GmailDraftIdentityRepair { + retired_legacy: Some(occupant), + retired_shadow, + }); + } + Ok(GmailDraftIdentityRepair::default()) +} + +fn is_gmail_draft_identity_repair_candidate(mount: &MountConfig, record: &EntityRecord) -> bool { + mount.connector == GMAIL_CONNECTOR_ID + && record + .remote_id + .as_str() + .starts_with(GMAIL_DRAFT_REMOTE_PREFIX) + && is_direct_gmail_draft_path(&record.path) +} + +fn is_direct_gmail_draft_path(path: &Path) -> bool { + let mut components = path.components(); + matches!(components.next(), Some(Component::Normal(component)) if component == "draft") + && matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() +} + +fn is_clean_legacy_gmail_draft_message_id(entity: &EntityRecord) -> bool { + entity.kind == EntityKind::Page + && !entity.remote_id.as_str().contains(':') + && !matches!( + entity.hydration, + HydrationState::Dirty | HydrationState::Conflicted + ) +} + fn connector_from_connection( credentials: &dyn CredentialStore, connection: &ConnectionRecord, @@ -349,6 +432,17 @@ pub(crate) fn validate_gmail_changed_frontmatter( ), )); } + if is_nested_outbound_child(context.relative_path) { + report.push(ValidationIssue::new( + "gmail_outbound_nested_unsupported", + context.relative_path, + Some(1), + "Gmail writes are only supported directly under draft/ or outbox/", + Some("move the Gmail Markdown file directly under draft/ or outbox/".to_string()), + )); + } else if is_direct_outbound_child(context.relative_path) { + validate_gmail_outbound_frontmatter(&mut report, context); + } Ok(report) } @@ -367,6 +461,17 @@ pub(crate) fn validate_gmail_create_frontmatter( )); } + if is_direct_outbound_child(context.relative_path) { + validate_gmail_outbound_frontmatter(&mut report, context); + } + + Ok(report) +} + +fn validate_gmail_outbound_frontmatter( + report: &mut ValidationReport, + context: SourceValidationContext<'_>, +) { let has_subject = frontmatter_string(&context.parsed.frontmatter.properties, "subject") .as_deref() .is_some_and(|subject| !subject.trim().is_empty()) @@ -405,22 +510,42 @@ pub(crate) fn validate_gmail_create_frontmatter( Some("remove attachment frontmatter".to_string()), )); } - - Ok(report) } fn gmail_draft_frontmatter_has_attachments( properties: &locality_core::canonical::FrontmatterProperties, ) -> bool { - properties.contains_key("attachment") - || properties.contains_key("attachments") + properties + .get("attachment") + .map(property_value_from_frontmatter) + .as_ref() + .is_some_and(|value| property_value_has_attachment_metadata(Some(value))) + || properties + .get("attachments") + .map(property_value_from_frontmatter) + .as_ref() + .is_some_and(|value| property_value_has_attachment_metadata(Some(value))) || matches!( properties.get("gmail").map(property_value_from_frontmatter), Some(PropertyValue::Object(gmail)) - if gmail.contains_key("attachment") || gmail.contains_key("attachments") + if property_value_has_attachment_metadata(gmail.get("attachment")) + || property_value_has_attachment_metadata(gmail.get("attachments")) ) } +fn property_value_has_attachment_metadata(value: Option<&PropertyValue>) -> bool { + match value { + None | Some(PropertyValue::Null) => false, + Some(PropertyValue::String(value)) => !value.trim().is_empty(), + Some(PropertyValue::List(values)) => values.iter().any(|value| !value.trim().is_empty()), + Some(PropertyValue::Array(values)) => values + .iter() + .any(|value| property_value_has_attachment_metadata(Some(value))), + Some(PropertyValue::Object(values)) => !values.is_empty(), + Some(PropertyValue::Bool(_) | PropertyValue::Number(_)) => true, + } +} + fn frontmatter_string_list( properties: &locality_core::canonical::FrontmatterProperties, key: &str, @@ -470,6 +595,15 @@ fn is_direct_outbound_child(path: &Path) -> bool { && components.next().is_none() } +fn is_nested_outbound_child(path: &Path) -> bool { + let mut components = path.components(); + matches!( + components.next(), + Some(Component::Normal(component)) if component == "draft" || component == "outbox" + ) && matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_some() +} + impl HydrationSource for GmailConnector { fn fetch_render(&self, request: &HydrationRequest) -> LocalityResult { let native = self.fetch(FetchRequest { @@ -570,8 +704,9 @@ mod tests { use locality_gmail::attachments::attachment_local_path; use locality_gmail::client::GmailApi; use locality_gmail::dto::{ - GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList, - GmailMessagePartBody, GmailMessageSendRequest, GmailThread, GmailThreadList, + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailDraftSendRequest, + GmailDraftUpdateRequest, GmailMessage, GmailMessageList, GmailMessagePartBody, + GmailMessageSendRequest, GmailThread, GmailThreadList, }; use super::*; @@ -793,10 +928,34 @@ mod tests { }) } + fn list_drafts( + &self, + _max_results: u32, + _page_token: Option<&str>, + _query: Option<&str>, + ) -> LocalityResult { + Ok(GmailDraftList::default()) + } + + fn get_draft_full(&self, draft_id: &str) -> LocalityResult { + Ok(GmailDraft { + id: draft_id.to_string(), + message: message_fixture("draft-msg-1"), + }) + } + fn create_draft(&self, _request: GmailDraftCreateRequest) -> LocalityResult { panic!("not used") } + fn update_draft( + &self, + _draft_id: &str, + _request: GmailDraftUpdateRequest, + ) -> LocalityResult { + panic!("not used") + } + fn send_message(&self, _request: GmailMessageSendRequest) -> LocalityResult { Ok(message_fixture("sent-msg-1")) } diff --git a/crates/localityd/src/pull.rs b/crates/localityd/src/pull.rs index 70f93190..d964b973 100644 --- a/crates/localityd/src/pull.rs +++ b/crates/localityd/src/pull.rs @@ -8,7 +8,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use locality_connector::{ChildContainer, EnumerateRequest, ListChildrenRequest}; -use locality_core::canonical::{parse_canonical_markdown, render_canonical_markdown}; +use locality_core::canonical::{ + ParsedCanonicalDocument, parse_canonical_markdown, render_canonical_markdown, +}; use locality_core::conflict::{ has_unresolved_conflict_markers, local_version_from_conflict_markers, render_inline_conflict_markdown_with_base, @@ -75,6 +77,7 @@ where S: MountRepository + EntityRepository + ShadowRepository + + locality_store::HydrationJobRepository + locality_store::VirtualMutationRepository + locality_store::FreshnessStateRepository + locality_store::RemoteObservationRepository, @@ -93,6 +96,7 @@ where S: MountRepository + EntityRepository + ShadowRepository + + locality_store::HydrationJobRepository + locality_store::VirtualMutationRepository + locality_store::FreshnessStateRepository + locality_store::RemoteObservationRepository, @@ -428,6 +432,7 @@ fn pull_mount_root( where S: EntityRepository + ShadowRepository + + locality_store::HydrationJobRepository + locality_store::FreshnessStateRepository + locality_store::RemoteObservationRepository, Source: SourceAdapter, @@ -456,7 +461,7 @@ where path: record.path.clone(), ..entry.clone() }; - store.save_entity(record).map_err(PullError::Store)?; + save_entity_after_gmail_draft_repair(store, mount, &projected_entry, record)?; rename_projection_if_needed(mount, existing.as_ref(), &projected_entry)?; if write_stub_if_needed(source, mount, &projected_entry, state_root)? { stubbed += 1; @@ -528,6 +533,7 @@ fn repair_missing_media_for_hydrated_entries( where S: EntityRepository + ShadowRepository + + locality_store::HydrationJobRepository + locality_store::FreshnessStateRepository + locality_store::RemoteObservationRepository, Source: SourceAdapter, @@ -697,6 +703,7 @@ fn pull_page_directory_path( where S: EntityRepository + ShadowRepository + + locality_store::HydrationJobRepository + locality_store::FreshnessStateRepository + locality_store::RemoteObservationRepository, Source: SourceAdapter, @@ -803,6 +810,7 @@ fn pull_virtual_directory_path( where S: EntityRepository + ShadowRepository + + locality_store::HydrationJobRepository + locality_store::FreshnessStateRepository + locality_store::RemoteObservationRepository, Source: SourceAdapter, @@ -843,13 +851,23 @@ where .iter() .map(|entry| entry.remote_id.clone()) .collect::>(); - crate::virtual_fs::prune_stale_virtual_children( - store, - &mount.mount_id, - &target.parent_path, - &returned_remote_ids, - ) - .map_err(PullError::Store)?; + if plain_files_directory_pull { + prune_stale_plain_directory_children( + store, + mount, + &target.parent_path, + &returned_remote_ids, + state_root, + )?; + } else { + crate::virtual_fs::prune_stale_virtual_children( + store, + &mount.mount_id, + &target.parent_path, + &returned_remote_ids, + ) + .map_err(PullError::Store)?; + } } enumerated = result.entries.len(); let remote_move_plan = if plain_files_directory_pull { @@ -884,7 +902,7 @@ where remote_edited_at: record.remote_edited_at.clone(), ..entry }; - store.save_entity(record).map_err(PullError::Store)?; + save_entity_after_gmail_draft_repair(store, mount, &projected_entry, record)?; if plain_files_directory_pull { rename_projection_if_needed(mount, existing.as_ref(), &projected_entry)?; } @@ -1030,6 +1048,155 @@ where })) } +fn prune_stale_plain_directory_children( + store: &mut S, + mount: &MountConfig, + parent_path: &Path, + returned_remote_ids: &BTreeSet, + state_root: Option<&Path>, +) -> Result +where + S: EntityRepository + ShadowRepository, +{ + let entities = store + .list_entities(&mount.mount_id) + .map_err(PullError::Store)?; + let mut delete_ids = BTreeSet::new(); + let mut projection_removals = Vec::new(); + for entity in entities.iter().filter(|entity| { + plain_entity_listing_parent_path(entity) == parent_path + && !returned_remote_ids.contains(&entity.remote_id) + }) { + let subtree = stale_plain_child_subtree(&entities, entity); + if subtree.iter().any(|entity| { + matches!( + entity.hydration, + HydrationState::Dirty | HydrationState::Conflicted + ) + }) { + continue; + } + if !stale_plain_child_subtree_can_be_removed(store, mount, &subtree, state_root)? { + continue; + } + for entity in subtree { + projection_removals.push(( + projection_content_path(state_root, mount, &entity.path)?, + entity.kind.clone(), + )); + delete_ids.insert(entity.remote_id.clone()); + } + } + + projection_removals.sort_by(|(left_path, _), (right_path, _)| { + right_path + .components() + .count() + .cmp(&left_path.components().count()) + .then_with(|| right_path.cmp(left_path)) + }); + projection_removals.dedup(); + for (path, kind) in projection_removals { + remove_clean_entity_projection(&path, &kind)?; + } + + let pruned = delete_ids.len(); + for remote_id in delete_ids { + store + .delete_entity(&mount.mount_id, &remote_id) + .map_err(PullError::Store)?; + } + Ok(pruned) +} + +fn stale_plain_child_subtree_can_be_removed( + store: &S, + mount: &MountConfig, + subtree: &[&EntityRecord], + state_root: Option<&Path>, +) -> Result +where + S: ShadowRepository, +{ + for entity in subtree + .iter() + .filter(|entity| entity.kind == EntityKind::Page) + { + let path = projection_content_path(state_root, mount, &entity.path)?; + if !can_replace_file(store, mount, entity, &path)? { + return Ok(false); + } + } + Ok(true) +} + +fn stale_plain_child_subtree<'a>( + entities: &'a [EntityRecord], + child: &EntityRecord, +) -> Vec<&'a EntityRecord> { + let subtree_root = plain_entity_subtree_root_path(child); + entities + .iter() + .filter(|entity| { + entity.remote_id == child.remote_id || entity.path.starts_with(&subtree_root) + }) + .collect() +} + +fn plain_entity_listing_parent_path(entity: &EntityRecord) -> PathBuf { + match entity.kind { + EntityKind::Page if is_page_document_path(&entity.path) => { + page_listing_parent_path(&entity.path) + } + EntityKind::Database + | EntityKind::Directory + | EntityKind::Asset + | EntityKind::Unknown(_) + | EntityKind::Page => entity + .path + .parent() + .filter(|parent| *parent != Path::new("")) + .map(Path::to_path_buf) + .unwrap_or_default(), + } +} + +fn plain_entity_subtree_root_path(entity: &EntityRecord) -> PathBuf { + match entity.kind { + EntityKind::Page if is_page_document_path(&entity.path) => { + page_container_path(&entity.path) + } + EntityKind::Database + | EntityKind::Directory + | EntityKind::Asset + | EntityKind::Unknown(_) + | EntityKind::Page => entity.path.clone(), + } +} + +fn remove_clean_entity_projection(path: &Path, kind: &EntityKind) -> Result<(), PullError> { + match kind { + EntityKind::Directory | EntityKind::Database => match std::fs::remove_dir(path) { + Ok(()) => Ok(()), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => + { + Ok(()) + } + Err(error) => Err(PullError::WriteFile { + path: path.to_path_buf(), + message: error.to_string(), + }), + }, + EntityKind::Page | EntityKind::Asset | EntityKind::Unknown(_) => { + remove_clean_projection(path) + } + } +} + fn should_hydrate_database_directory_rows(row_count: usize, limit: isize) -> bool { limit >= 0 && row_count <= limit as usize } @@ -1068,7 +1235,11 @@ fn collect_connector_directory_descendants( visited: &mut BTreeSet, ) -> Result where - S: EntityRepository + ShadowRepository, + S: EntityRepository + + ShadowRepository + + locality_store::HydrationJobRepository + + locality_store::FreshnessStateRepository + + locality_store::RemoteObservationRepository, Source: SourceAdapter, { let mut report = ConnectorDirectoryTraversalReport::default(); @@ -1108,13 +1279,13 @@ where .iter() .map(|entry| entry.remote_id.clone()) .collect::>(); - crate::virtual_fs::prune_stale_virtual_children( + prune_stale_plain_directory_children( store, - &mount.mount_id, + mount, &directory.path, &returned_remote_ids, - ) - .map_err(PullError::Store)?; + state_root, + )?; } report.enumerated += result.entries.len(); let remote_move_plan = remote_move_plan(store, mount, &result.entries, state_root)?; @@ -1136,7 +1307,7 @@ where remote_edited_at: record.remote_edited_at.clone(), ..entry }; - store.save_entity(record).map_err(PullError::Store)?; + save_entity_after_gmail_draft_repair(store, mount, &projected_entry, record)?; rename_projection_if_needed(mount, existing.as_ref(), &projected_entry)?; if write_stub_if_needed(source, mount, &projected_entry, state_root)? { report.stubbed += 1; @@ -1295,6 +1466,125 @@ fn virtual_child_entity_record(entry: TreeEntry, existing: Option<&EntityRecord> record } +fn save_entity_after_gmail_draft_repair( + store: &mut S, + mount: &MountConfig, + entry: &TreeEntry, + record: EntityRecord, +) -> Result<(), PullError> +where + S: EntityRepository + + ShadowRepository + + locality_store::HydrationJobRepository + + locality_store::FreshnessStateRepository + + locality_store::RemoteObservationRepository, +{ + let replacement_frontmatter = gmail_draft_replacement_frontmatter(entry); + let repair = crate::gmail::repair_legacy_gmail_draft_message_id_collision( + store, + mount, + &record, + Some(&replacement_frontmatter), + ) + .map_err(PullError::Store)?; + store.save_entity(record).map_err(PullError::Store)?; + repair_gmail_draft_projection_after_identity_repair( + mount, + entry, + &replacement_frontmatter, + &repair, + ) +} + +fn gmail_draft_replacement_frontmatter(entry: &TreeEntry) -> String { + entry + .stub_frontmatter + .clone() + .unwrap_or_else(|| stub_frontmatter(entry)) +} + +fn repair_gmail_draft_projection_after_identity_repair( + mount: &MountConfig, + entry: &TreeEntry, + replacement_frontmatter: &str, + repair: &crate::gmail::GmailDraftIdentityRepair, +) -> Result<(), PullError> { + if mount.projection.uses_virtual_filesystem() || repair.retired_legacy.is_none() { + return Ok(()); + } + if entry.kind != EntityKind::Page { + return Ok(()); + } + + let path = mount.root.join(&entry.path); + if !path.exists() { + return Ok(()); + } + let contents = std::fs::read_to_string(&path).map_err(|error| PullError::ReadFile { + path: path.clone(), + message: error.to_string(), + })?; + let parsed = match parse_canonical_markdown(&contents) { + Ok(parsed) => parsed, + Err(_) => return Ok(()), + }; + if parsed.document.is_stub() + && legacy_stub_frontmatter_has_no_local_drift( + &parsed, + entry, + replacement_frontmatter, + repair, + ) + { + write_atomic(&path, stub_markdown(entry)?)?; + return Ok(()); + } + + let Some(retired_shadow) = repair.retired_shadow.as_ref() else { + return Ok(()); + }; + if !parsed_matches_shadow(&parsed, retired_shadow) { + return Ok(()); + } + + write_atomic( + &path, + render_canonical_markdown(&CanonicalDocument::new( + replacement_frontmatter.to_string(), + parsed.document.body, + )), + ) +} + +fn legacy_stub_frontmatter_has_no_local_drift( + parsed: &ParsedCanonicalDocument, + entry: &TreeEntry, + replacement_frontmatter: &str, + repair: &crate::gmail::GmailDraftIdentityRepair, +) -> bool { + let Some(legacy) = repair.retired_legacy.as_ref() else { + return false; + }; + if parsed.remote_id() != Some(&legacy.remote_id) { + return false; + } + + let expected = render_canonical_markdown(&CanonicalDocument::new( + replacement_frontmatter.to_string(), + format!("{}\n", CanonicalDocument::STUB_MARKER), + )); + let Ok(expected) = parse_canonical_markdown(&expected) else { + return false; + }; + + let mut actual_frontmatter = parsed.frontmatter.clone(); + let Some(loc) = actual_frontmatter.loc.as_mut() else { + return false; + }; + loc.id = Some(entry.remote_id.clone()); + actual_frontmatter == expected.frontmatter +} + #[derive(Debug)] struct VirtualDirectoryTarget { parent_path: PathBuf, @@ -2694,18 +2984,20 @@ mod tests { ListChildrenRequest, ListChildrenResult, NativeEntity, ObserveRequest, ParsedEntity, }; use locality_core::LocalityResult; - use locality_core::canonical::render_canonical_markdown; - use locality_core::freshness::RemoteObservation; + use locality_core::canonical::{parse_canonical_markdown, render_canonical_markdown}; + use locality_core::freshness::{FreshnessTier, RemoteObservation}; use locality_core::hydration::{HydrationReason, HydrationRequest}; use locality_core::model::{CanonicalDocument, EntityKind, HydrationState, MountId, RemoteId}; use locality_core::planner::PushOperationKind; use locality_core::shadow::{ShadowDocument, segment_markdown_body}; use locality_store::{ - EntityRecord, EntityRepository, InMemoryStateStore, MountRepository, ProjectionMode, - ShadowRepository, + EntityRecord, EntityRepository, FreshnessStateRecord, FreshnessStateRepository, + HydrationJobRecord, HydrationJobRepository, InMemoryStateStore, MountRepository, + ProjectionMode, RemoteObservationRecord, RemoteObservationRepository, ShadowRepository, + SqliteStateStore, StoreError, }; - use super::{can_replace_file, write_atomic}; + use super::{PullError, can_replace_file, write_atomic}; use crate::hydration::{HydratedAsset, HydratedAssetMedia, HydratedEntity, HydrationSource}; use crate::source::{SourceAdapter, SourcePushValidator}; use locality_store::MountConfig; @@ -3187,6 +3479,613 @@ mod tests { ); } + #[test] + fn pull_plain_gmail_draft_directory_removes_clean_stale_draft_projection() { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_folder_id = RemoteId::new("gmail-folder:draft"); + let stale_draft_id = RemoteId::new("gmail-draft:draft-stale"); + let current_draft_id = RemoteId::new("gmail-draft:draft-current"); + let stale_path = PathBuf::from("draft/stale.md"); + let current_path = PathBuf::from("draft/current.md"); + let stale_frontmatter = gmail_draft_frontmatter(&stale_draft_id); + let current_frontmatter = gmail_draft_frontmatter(¤t_draft_id); + let stale_body = "stale remote draft\n"; + let current_body = "current remote draft\n"; + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + stale_draft_id.clone(), + EntityKind::Page, + "Stale Draft", + &stale_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save stale draft"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + current_draft_id.clone(), + EntityKind::Page, + "Current Draft", + ¤t_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save current draft"); + store + .save_shadow( + &fixture.mount_id, + shadow_document(&stale_draft_id, &stale_frontmatter, stale_body), + ) + .expect("save stale shadow"); + store + .save_shadow( + &fixture.mount_id, + shadow_document(¤t_draft_id, ¤t_frontmatter, current_body), + ) + .expect("save current shadow"); + let stale_absolute = fixture.root.join(&stale_path); + let current_absolute = fixture.root.join(¤t_path); + std::fs::create_dir_all(fixture.root.join("draft")).expect("create draft directory"); + write_atomic( + &stale_absolute, + render_canonical_markdown(&CanonicalDocument::new( + stale_frontmatter, + stale_body.to_string(), + )), + ) + .expect("write stale draft projection"); + write_atomic( + ¤t_absolute, + render_canonical_markdown(&CanonicalDocument::new( + current_frontmatter, + current_body.to_string(), + )), + ) + .expect("write current draft projection"); + let source = FakePullSource::new(Vec::new(), Vec::new()).with_children( + &draft_folder_id, + vec![tree_entry( + &fixture.mount_id, + ¤t_draft_id, + "Current Draft", + "draft/current.md", + HydrationState::Stub, + )], + ); + + let report = super::pull_virtual_directory_path( + &mut store, + &source, + &mount, + Path::new("draft"), + fixture.root.join("draft"), + None, + ) + .expect("pull draft directory") + .expect("draft directory report"); + + assert!(report.ok); + assert_eq!(report.enumerated, 1); + assert!( + store + .get_entity(&fixture.mount_id, &stale_draft_id) + .expect("stale draft lookup") + .is_none() + ); + assert!( + !stale_absolute.exists(), + "stale draft projection should be removed" + ); + assert!( + store + .get_entity(&fixture.mount_id, ¤t_draft_id) + .expect("current draft lookup") + .is_some() + ); + assert!( + current_absolute.exists(), + "current draft projection should remain" + ); + } + + #[test] + fn pull_gmail_draft_repairs_legacy_message_id_entity_collision() { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save legacy draft"); + let source = FakePullSource::new( + vec![tree_entry( + &fixture.mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + )], + Vec::new(), + ); + + let report = + super::pull_mount_root(&mut store, &source, &mount, fixture.root.clone(), None) + .expect("pull root"); + + assert_eq!(report.enumerated, 1); + assert!( + store + .get_entity(&fixture.mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert_eq!( + store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("draft lookup") + .expect("draft entity") + .path, + PathBuf::from(draft_path) + ); + } + + #[test] + fn pull_gmail_draft_does_not_repair_dirty_legacy_message_id_collision() { + for hydration in [HydrationState::Dirty, HydrationState::Conflicted] { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(hydration.clone()), + ) + .expect("save dirty legacy draft"); + let source = FakePullSource::new( + vec![tree_entry( + &fixture.mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + )], + Vec::new(), + ); + + let error = + super::pull_mount_root(&mut store, &source, &mount, fixture.root.clone(), None) + .expect_err("dirty legacy collision should not be repaired"); + + assert!(matches!( + error, + PullError::Store(StoreError::DuplicateEntityPath { .. }) + )); + assert_eq!( + store + .get_entity(&fixture.mount_id, &legacy_message_id) + .expect("legacy lookup") + .expect("legacy entity") + .hydration, + hydration + ); + } + } + + #[test] + fn pull_gmail_draft_repairs_projection_and_sqlite_side_state() { + let fixture = PullFixture::new(); + let state_root = fixture.root.join(".loc-state"); + let mut store = SqliteStateStore::open(state_root.clone()).expect("open sqlite store"); + let mount_id = MountId::new("gmail-main"); + let mount = MountConfig::new(mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let old_frontmatter = gmail_draft_frontmatter(&legacy_message_id); + let new_frontmatter = gmail_draft_frontmatter(&draft_remote_id); + let body = "Draft body stays editable.\n"; + let old_document = CanonicalDocument::new(old_frontmatter.clone(), body.to_string()); + write_atomic( + &fixture.root.join(draft_path), + render_canonical_markdown(&old_document), + ) + .expect("write legacy projection"); + store + .save_entity( + EntityRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save legacy draft"); + store + .save_shadow( + &mount_id, + shadow_document(&legacy_message_id, &old_frontmatter, body), + ) + .expect("save legacy shadow"); + store + .upsert_hydration_job(HydrationJobRecord::from(HydrationRequest::new( + mount_id.clone(), + legacy_message_id.clone(), + fixture.root.join(draft_path), + HydrationState::Hydrated, + HydrationReason::ExplicitPull, + ))) + .expect("save legacy hydration job"); + store + .save_freshness_state(FreshnessStateRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + FreshnessTier::Warm, + )) + .expect("save legacy freshness"); + store + .save_remote_observation(RemoteObservationRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + "observed-old", + )) + .expect("save legacy observation"); + let mut entry = tree_entry( + &mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + ); + entry.stub_frontmatter = Some(new_frontmatter.clone()); + let source = FakePullSource::new(vec![entry], Vec::new()); + + let report = + super::pull_mount_root(&mut store, &source, &mount, fixture.root.clone(), None) + .expect("pull root"); + + assert_eq!(report.enumerated, 1); + assert!( + store + .get_entity(&mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert_eq!( + store + .get_entity(&mount_id, &draft_remote_id) + .expect("draft lookup") + .expect("draft entity") + .path, + PathBuf::from(draft_path) + ); + let rewritten = std::fs::read_to_string(fixture.root.join(draft_path)) + .expect("read rewritten projection"); + let parsed = parse_canonical_markdown(&rewritten).expect("parse rewritten projection"); + assert_eq!(parsed.remote_id(), Some(&draft_remote_id)); + assert_eq!(parsed.document.frontmatter, new_frontmatter); + assert_eq!(parsed.document.body, body); + assert!( + store + .get_shadow_record(&mount_id, &legacy_message_id) + .expect("legacy shadow lookup") + .is_none() + ); + let migrated_shadow = store + .load_shadow(&mount_id, &draft_remote_id) + .expect("load migrated shadow"); + assert_eq!(migrated_shadow.entity_id, draft_remote_id); + assert_eq!(migrated_shadow.frontmatter, new_frontmatter); + assert_eq!(migrated_shadow.rendered_body, body); + assert!( + !store + .list_hydration_jobs() + .expect("list hydration jobs") + .iter() + .any(|job| job.remote_id == legacy_message_id) + ); + assert!( + store + .get_freshness_state(&mount_id, &legacy_message_id) + .expect("legacy freshness lookup") + .is_none() + ); + assert!( + store + .get_remote_observation(&mount_id, &legacy_message_id) + .expect("legacy observation lookup") + .is_none() + ); + let _ = std::fs::remove_dir_all(state_root); + } + + #[test] + fn pull_gmail_draft_repairs_legacy_stub_projection_identity() { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Stub), + ) + .expect("save legacy draft"); + let mut legacy_entry = tree_entry( + &fixture.mount_id, + &legacy_message_id, + "Hello", + draft_path, + HydrationState::Stub, + ); + legacy_entry.stub_frontmatter = Some(gmail_draft_frontmatter(&legacy_message_id)); + write_atomic( + &fixture.root.join(draft_path), + super::stub_markdown(&legacy_entry).expect("legacy stub markdown"), + ) + .expect("write legacy stub"); + let mut draft_entry = tree_entry( + &fixture.mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + ); + draft_entry.stub_frontmatter = Some(gmail_draft_frontmatter(&draft_remote_id)); + let source = FakePullSource::new(vec![draft_entry], Vec::new()); + + super::pull_mount_root(&mut store, &source, &mount, fixture.root.clone(), None) + .expect("pull root"); + + let rewritten = + std::fs::read_to_string(fixture.root.join(draft_path)).expect("read rewritten stub"); + let parsed = parse_canonical_markdown(&rewritten).expect("parse rewritten stub"); + assert_eq!(parsed.remote_id(), Some(&draft_remote_id)); + assert!(parsed.document.is_stub()); + } + + #[test] + fn pull_gmail_draft_does_not_rewrite_locally_edited_stub_projection() { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Stub), + ) + .expect("save legacy draft"); + let locally_edited_stub = render_canonical_markdown(&CanonicalDocument::new( + gmail_draft_frontmatter(&legacy_message_id), + format!( + "{}\n\nLocal note that should not be overwritten.\n", + CanonicalDocument::STUB_MARKER + ), + )); + write_atomic(&fixture.root.join(draft_path), locally_edited_stub.clone()) + .expect("write locally edited stub"); + let mut draft_entry = tree_entry( + &fixture.mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + ); + draft_entry.stub_frontmatter = Some(gmail_draft_frontmatter(&draft_remote_id)); + let source = FakePullSource::new(vec![draft_entry], Vec::new()); + + super::pull_mount_root(&mut store, &source, &mount, fixture.root.clone(), None) + .expect("pull root"); + + let after = + std::fs::read_to_string(fixture.root.join(draft_path)).expect("read projection"); + assert_eq!(after, locally_edited_stub); + assert!( + store + .get_entity(&fixture.mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert!( + store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("draft lookup") + .is_some() + ); + } + + #[test] + fn pull_gmail_draft_does_not_rewrite_frontmatter_edited_stub_projection() { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()); + store.save_mount(mount.clone()).expect("save mount"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Stub), + ) + .expect("save legacy draft"); + let edited_frontmatter = gmail_draft_frontmatter(&legacy_message_id) + .replace("subject: Hello\n", "subject: Edited locally\n"); + let locally_edited_stub = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + format!("{}\n", CanonicalDocument::STUB_MARKER), + )); + write_atomic(&fixture.root.join(draft_path), locally_edited_stub.clone()) + .expect("write frontmatter-edited stub"); + let mut draft_entry = tree_entry( + &fixture.mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + ); + draft_entry.stub_frontmatter = Some(gmail_draft_frontmatter(&draft_remote_id)); + let source = FakePullSource::new(vec![draft_entry], Vec::new()); + + super::pull_mount_root(&mut store, &source, &mount, fixture.root.clone(), None) + .expect("pull root"); + + let after = + std::fs::read_to_string(fixture.root.join(draft_path)).expect("read projection"); + assert_eq!(after, locally_edited_stub); + assert!( + store + .get_entity(&fixture.mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert!( + store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("draft lookup") + .is_some() + ); + } + + #[test] + fn pull_gmail_draft_virtual_directory_repairs_legacy_message_id_entity_collision() { + let fixture = PullFixture::new(); + let mut store = InMemoryStateStore::new(); + let mount = MountConfig::new(fixture.mount_id.clone(), "gmail", fixture.root.clone()) + .projection(ProjectionMode::LinuxFuse); + store.save_mount(mount.clone()).expect("save mount"); + let draft_folder_id = RemoteId::new("gmail-folder:draft"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save legacy draft"); + let source = FakePullSource::new(Vec::new(), Vec::new()).with_incremental_children( + &draft_folder_id, + vec![tree_entry( + &fixture.mount_id, + &draft_remote_id, + "Hello", + draft_path, + HydrationState::Stub, + )], + ); + + let report = super::pull_virtual_directory_path( + &mut store, + &source, + &mount, + Path::new("draft"), + fixture.root.join("draft"), + None, + ) + .expect("pull virtual draft directory") + .expect("draft directory report"); + + assert_eq!(report.enumerated, 1); + assert!( + store + .get_entity(&fixture.mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert_eq!( + store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("draft lookup") + .expect("draft entity") + .path, + PathBuf::from(draft_path) + ); + } + #[test] fn pull_plain_directory_moves_existing_projected_child_path() { let fixture = PullFixture::new(); @@ -3429,6 +4328,7 @@ mod tests { struct FakePullSource { entries: Vec, children: BTreeMap>, + incremental_children: BTreeSet, rendered: BTreeMap, schemas: BTreeMap, fetch_count: Arc, @@ -3442,6 +4342,7 @@ mod tests { Self { entries, children: BTreeMap::new(), + incremental_children: BTreeSet::new(), rendered: rendered .into_iter() .map(|entity| (entity.shadow.entity_id.clone(), entity)) @@ -3469,6 +4370,16 @@ mod tests { self.children.insert(parent_id.clone(), entries); self } + + fn with_incremental_children( + mut self, + parent_id: &RemoteId, + entries: Vec, + ) -> Self { + self.children.insert(parent_id.clone(), entries); + self.incremental_children.insert(parent_id.clone()); + self + } } impl Connector for FakePullSource { @@ -3505,9 +4416,12 @@ mod tests { | ChildContainer::PageChildren(remote_id) => remote_id, ChildContainer::Root => RemoteId::new("root"), }; - Ok(ListChildrenResult::complete( - self.children.get(&key).cloned().unwrap_or_default(), - )) + let entries = self.children.get(&key).cloned().unwrap_or_default(); + if self.incremental_children.contains(&key) { + Ok(ListChildrenResult::incremental(entries)) + } else { + Ok(ListChildrenResult::complete(entries)) + } } fn fetch(&self, _request: FetchRequest) -> LocalityResult { @@ -3617,6 +4531,24 @@ mod tests { } } + fn gmail_draft_frontmatter(remote_id: &RemoteId) -> String { + format!( + "loc:\n id: \"{}\"\n type: page\n connector: gmail\n synced_at: \"2026-06-11T00:00:00.000Z\"\n remote_edited_at: \"2026-06-11T00:00:00.000Z\"\ntitle: Hello\nsubject: Hello\nto: [\"ann@example.com\"]\ngmail:\n mailbox: draft\n message_count: 1\n", + remote_id.0 + ) + } + + fn shadow_document(remote_id: &RemoteId, frontmatter: &str, body: &str) -> ShadowDocument { + ShadowDocument::from_synced_body( + remote_id.clone(), + body.to_string(), + frontmatter.lines().count() + 3, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter(frontmatter.to_string()) + } + impl Drop for PullFixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); diff --git a/crates/localityd/src/push.rs b/crates/localityd/src/push.rs index 91070e4b..1b25b7e7 100644 --- a/crates/localityd/src/push.rs +++ b/crates/localityd/src/push.rs @@ -393,6 +393,7 @@ where store, source, state_root: state_root.map(Path::to_path_buf), + resume_cleanup_plan: None, }; execute_journaled_push_with_host(&mut host, execution_request) }; @@ -475,17 +476,33 @@ where let Some(plan) = prepared.pipeline.plan.as_ref() else { return Ok(None); }; - if plan.operations.is_empty() - || !plan - .operations - .iter() - .all(|operation| matches!(operation, PushOperation::CreateEntity { .. })) - { + if !gmail_send_replay_plan_can_be_ambiguous(plan) { return Ok(None); } let Some(journal) = latest_ambiguous_gmail_send_journal(store, &prepared.mount.mount_id, plan)? else { + if let Some(journal) = + latest_completed_gmail_send_overlap_journal(store, &prepared.mount.mount_id, plan)? + { + let error = LocalityError::Guardrail( + "a previous Gmail send for this draft was already applied but cannot be safely resumed as part of this broader push; reconcile or remove that pending item before retrying" + .to_string(), + ); + + return Ok(Some(PushJobReport { + target_path: prepared.absolute_path.clone(), + mount_id: prepared.mount.mount_id.clone(), + entity_id: prepared.entity.remote_id.clone(), + pipeline: prepared.pipeline.clone(), + readable_diff: prepared.readable_diff.clone(), + action: PushJobAction::Failed, + execution: None, + push_id: Some(journal.push_id), + journal_status: Some(journal.status), + error: Some(PushJobError::from(error)), + })); + } return Ok(None); }; let error = LocalityError::Guardrail( @@ -507,7 +524,7 @@ where })) } -fn latest_ambiguous_gmail_send_journal( +fn latest_completed_gmail_send_overlap_journal( store: &S, mount_id: &MountId, plan: &PushPlan, @@ -518,7 +535,7 @@ where let mut latest = None; for journal in store.list_journal().map_err(LocalityError::from)? { if journal.mount_id != *mount_id - || !journal_created_entity_source_paths_match(&journal.plan, plan) + || !completed_gmail_send_overlap_requires_guard(&journal, plan) { continue; } @@ -530,6 +547,65 @@ where } } + Ok(latest) +} + +fn completed_gmail_send_overlap_requires_guard(journal: &JournalEntry, plan: &PushPlan) -> bool { + if !matches!( + journal.status, + JournalStatus::Applying | JournalStatus::Applied | JournalStatus::Failed(_) + ) || resumable_plan_matches(journal, plan, "gmail") + { + return false; + } + + if resumable_gmail_draft_send_effect_ids(journal, "gmail").is_some() { + return gmail_draft_send_plans_overlap(&journal.plan, plan); + } + + resumable_created_entity_effects(journal) + && journal.plan.operations.iter().all(is_create_operation) + && create_entity_plans_overlap(&journal.plan, plan) +} + +fn gmail_draft_send_plans_overlap(left: &PushPlan, right: &PushPlan) -> bool { + let left_ids = gmail_draft_send_move_entity_ids(left); + let right_ids = gmail_draft_send_move_entity_ids(right); + !left_ids.is_empty() + && !right_ids.is_empty() + && left_ids + .into_iter() + .any(|left_id| right_ids.contains(left_id)) +} + +fn create_entity_plans_overlap(left: &PushPlan, right: &PushPlan) -> bool { + let right_sources = plan_create_entity_sources(right); + plan_create_entity_sources(left) + .into_iter() + .any(|source| right_sources.contains(&source)) +} + +fn latest_ambiguous_gmail_send_journal( + store: &S, + mount_id: &MountId, + plan: &PushPlan, +) -> LocalityResult> +where + S: JournalRepository, +{ + let mut latest = None; + for journal in store.list_journal().map_err(LocalityError::from)? { + if journal.mount_id != *mount_id || !ambiguous_gmail_send_plans_match(&journal.plan, plan) { + continue; + } + if latest + .as_ref() + .is_none_or(|current| journal_is_newer(&journal, current)) + { + latest = Some(journal); + } + } + Ok(latest.filter(|journal: &JournalEntry| { journal.apply_effects.is_empty() && ambiguous_gmail_send_status(&journal.status) })) @@ -547,6 +623,63 @@ fn ambiguous_gmail_send_status(status: &JournalStatus) -> bool { } } +fn gmail_send_replay_plan_can_be_ambiguous(plan: &PushPlan) -> bool { + !plan.operations.is_empty() + && (plan + .operations + .iter() + .all(|operation| matches!(operation, PushOperation::CreateEntity { .. })) + || !gmail_draft_send_move_entity_ids(plan).is_empty()) +} + +fn ambiguous_gmail_send_plans_match(left: &PushPlan, right: &PushPlan) -> bool { + let right_create_sources = plan_create_entity_sources(right); + if !right_create_sources.is_empty() && journal_created_entity_source_paths_match(left, right) { + return true; + } + + let left_ids = gmail_draft_send_move_entity_ids(left); + let right_ids = gmail_draft_send_move_entity_ids(right); + if left_ids.is_empty() || right_ids.is_empty() { + return false; + } + right_ids + .into_iter() + .any(|right_id| left_ids.contains(&right_id)) +} + +fn gmail_draft_send_move_entity_ids(plan: &PushPlan) -> BTreeSet<&RemoteId> { + plan.operations + .iter() + .filter_map(|operation| match operation { + PushOperation::MoveEntity { + entity_id, + new_parent_id, + .. + } if entity_id.0.starts_with("gmail-draft:") + && new_parent_id.0 == "gmail-folder:outbox" => + { + Some(entity_id) + } + _ => None, + }) + .collect() +} + +fn is_gmail_draft_send_move(operation: &PushOperation) -> bool { + match operation { + PushOperation::MoveEntity { + entity_id, + new_parent_id, + .. + } => { + entity_id.as_str().starts_with("gmail-draft:") + && new_parent_id.as_str() == "gmail-folder:outbox" + } + _ => false, + } +} + fn resume_failed_applied_reconciliation( store: &mut S, source: &Source, @@ -596,6 +729,7 @@ where store, source, state_root: state_root.map(Path::to_path_buf), + resume_cleanup_plan: Some(plan.clone()), }; host.update_status(&journal.push_id, JournalStatus::Applied)?; match host.reconcile(PushReconcileRequest { @@ -666,7 +800,7 @@ where if journal.mount_id != *mount_id || !matches!( journal.status, - JournalStatus::Failed(_) | JournalStatus::Applied + JournalStatus::Failed(_) | JournalStatus::Applied | JournalStatus::Applying ) || !resumable_plan_matches(&journal, plan, connector) { @@ -688,7 +822,7 @@ where } if journal.status == JournalStatus::Applied || !journal.apply_effects.is_empty() { return Err(LocalityError::InvalidState(format!( - "journal `{}` was applied but its effects do not safely cover the prepared plan; refusing to apply again", + "journal `{}` reached apply but its effects do not safely cover the prepared plan; refusing to apply again", journal.push_id.0 ))); } @@ -700,11 +834,17 @@ fn resumable_plan_matches(journal: &JournalEntry, plan: &PushPlan, connector: &s return true; } - create_only_effects_may_change_parent_for_connector(connector) + if create_only_effects_may_change_parent_for_connector(connector) && resumable_created_entity_effects(journal) && plan.operations.iter().all(is_create_operation) && journal.plan.operations.iter().all(is_create_operation) && journal_created_entity_sources_match(journal, plan) + { + return true; + } + + resumable_gmail_draft_send_effect_ids(journal, connector).is_some() + && gmail_draft_send_plans_match(&journal.plan, plan) } fn resumable_created_entity_effects(journal: &JournalEntry) -> bool { @@ -716,6 +856,10 @@ fn resumable_created_entity_effects(journal: &JournalEntry) -> bool { } fn resumable_entity_effect_ids(journal: &JournalEntry, connector: &str) -> Option> { + if let Some(remote_ids) = resumable_gmail_draft_send_effect_ids(journal, connector) { + return Some(remote_ids); + } + if journal.plan.operations.is_empty() || journal.apply_effects.len() != journal.plan.operations.len() { @@ -798,6 +942,107 @@ fn resumable_entity_effect_ids(journal: &JournalEntry, connector: &str) -> Optio (seen_operations.len() == journal.plan.operations.len()).then_some(changed_remote_ids) } +fn resumable_gmail_draft_send_effect_ids( + journal: &JournalEntry, + connector: &str, +) -> Option> { + if connector != "gmail" { + return None; + } + + let draft_moves = journal + .plan + .operations + .iter() + .enumerate() + .filter_map(|(operation_index, operation)| match operation { + PushOperation::MoveEntity { entity_id, .. } if is_gmail_draft_send_move(operation) => { + Some((operation_index, entity_id.clone())) + } + _ => None, + }) + .collect::>(); + if draft_moves.is_empty() { + return None; + } + let draft_ids = draft_moves.values().cloned().collect::>(); + for (operation_index, operation) in journal.plan.operations.iter().enumerate() { + match operation { + PushOperation::MoveEntity { .. } if draft_moves.contains_key(&operation_index) => {} + PushOperation::UpdateProperties { entity_id, .. } + | PushOperation::UpdateEntityBody { entity_id, .. } + if draft_ids.contains(entity_id) => {} + _ => return None, + } + } + + let mut archived = BTreeSet::new(); + let mut created = BTreeMap::new(); + for effect in &journal.apply_effects { + match effect { + JournalApplyEffect::ArchivedEntity { + operation_index, + entity_id, + .. + } if draft_moves.get(operation_index) == Some(entity_id) => { + if !archived.insert(*operation_index) { + return None; + } + } + JournalApplyEffect::CreatedEntity { + operation_index, + parent_id, + entity_id, + .. + } if draft_moves.contains_key(operation_index) + && parent_id.as_str() == "gmail-folder:sent" + && !entity_id.as_str().trim().is_empty() => + { + if created + .insert(*operation_index, entity_id.clone()) + .is_some() + { + return None; + } + } + _ => return None, + } + } + + if archived.len() == draft_moves.len() && created.len() == draft_moves.len() { + Some(created.into_values().collect()) + } else { + None + } +} + +fn gmail_draft_send_plans_match(left: &PushPlan, right: &PushPlan) -> bool { + match ( + resumable_gmail_draft_send_plan_move_ids(left), + resumable_gmail_draft_send_plan_move_ids(right), + ) { + (Some(left_ids), Some(right_ids)) => left_ids == right_ids, + _ => false, + } +} + +fn resumable_gmail_draft_send_plan_move_ids(plan: &PushPlan) -> Option> { + let move_ids = gmail_draft_send_move_entity_ids(plan); + if move_ids.is_empty() { + return None; + } + for operation in &plan.operations { + match operation { + PushOperation::MoveEntity { entity_id, .. } if move_ids.contains(entity_id) => {} + PushOperation::UpdateProperties { entity_id, .. } + | PushOperation::UpdateEntityBody { entity_id, .. } + if move_ids.contains(entity_id) => {} + _ => return None, + } + } + Some(move_ids) +} + fn create_only_effects_may_change_parent(connector: &str, plan: &PushPlan) -> bool { create_only_effects_may_change_parent_for_connector(connector) && plan @@ -3577,6 +3822,7 @@ struct DaemonPushHost<'a, S, Source: ?Sized> { store: &'a mut S, source: &'a Source, state_root: Option, + resume_cleanup_plan: Option, } impl JournalStore for DaemonPushHost<'_, S, Source> @@ -3740,6 +3986,7 @@ where .map_err(LocalityError::from)? .ok_or_else(|| StoreError::MountMissing(request.mount_id.clone())) .map_err(LocalityError::from)?; + let gmail_connector = mount.connector == "gmail" && self.source.kind().0 == "gmail"; let planned_moves = request .plan .operations @@ -3755,6 +4002,26 @@ where }) .collect::>(); for (operation_index, entity_id, new_parent_id) in planned_moves.iter().copied() { + let operation = &request.plan.operations[operation_index]; + if gmail_connector + && is_gmail_draft_send_move(operation) + && apply_effects_have_gmail_draft_send_created_entity( + request.apply_effects, + operation_index, + ) + { + if !apply_effects_have_archived_entity( + request.apply_effects, + operation_index, + entity_id, + ) { + return Err(LocalityError::InvalidState(format!( + "gmail draft send move for `{}` did not report an archived-entity effect", + entity_id.0 + ))); + } + continue; + } if !request.changed_remote_ids.contains(entity_id) { return Err(LocalityError::InvalidState(format!( "move operation for `{}` did not report the entity as changed", @@ -3843,29 +4110,29 @@ where database_reconciled_ids.push(entity_id.clone()); continue; } - let Some(PushOperation::CreateEntity { + let Some(created_operation) = created_entity_reconcile_operation( + request.plan.operations.get(*operation_index), + gmail_connector, + ) else { + continue; + }; + let CreatedEntityReconcileOperation { title, - properties: _, - body: _, source_path, parent_kind, - .. - }) = request.plan.operations.get(*operation_index) - else { - continue; - }; + } = created_operation; let entity_path = created_entity_reconcile_path( self.store, request.mount_id, - source_path, - parent_kind, + &source_path, + &parent_kind, parent_id, )?; let mut entity = EntityRecord::new( request.mount_id.clone(), entity_id.clone(), EntityKind::Page, - title.clone(), + title, entity_path, ) .with_hydration(HydrationState::Stub); @@ -3883,7 +4150,7 @@ where self.store, request.mount_id, &entity.path, - parent_kind, + &parent_kind, parent_id, entity_id, &rendered, @@ -3997,35 +4264,81 @@ where reconciled_remote_ids.push(remote_id); } + let gmail_draft_send_cleanup_plan = self + .resume_cleanup_plan + .as_ref() + .filter(|plan| gmail_draft_send_plans_match(request.plan, plan)) + .unwrap_or(request.plan); for (operation_index, _, entity, _) in &created_readbacks { - let Some(PushOperation::CreateEntity { + if let Some(PushOperation::CreateEntity { title, properties, body, source_path, .. }) = request.plan.operations.get(*operation_index) - else { + { + let clear_source_mutation = remove_stale_created_entity_source_path( + self.state_root.as_deref(), + &mount, + title, + properties, + body, + source_path, + &entity.path, + )?; + if clear_source_mutation + && let Some(mutation) = self + .store + .find_virtual_mutation_by_path(request.mount_id, source_path) + .map_err(LocalityError::from)? + { + self.store + .delete_virtual_mutation(request.mount_id, &mutation.local_id) + .map_err(LocalityError::from)?; + } continue; - }; - let clear_source_mutation = remove_stale_created_entity_source_path( - self.state_root.as_deref(), - &mount, - title, - properties, - body, - source_path, - &entity.path, - )?; - if clear_source_mutation - && let Some(mutation) = self - .store - .find_virtual_mutation_by_path(request.mount_id, source_path) - .map_err(LocalityError::from)? + } + if let Some(PushOperation::MoveEntity { + entity_id, + new_parent_id, + projected_path, + .. + }) = request.plan.operations.get(*operation_index) + && gmail_connector + && is_gmail_draft_send_move(&request.plan.operations[*operation_index]) { - self.store - .delete_virtual_mutation(request.mount_id, &mutation.local_id) - .map_err(LocalityError::from)?; + let (cleanup_parent_id, cleanup_projected_path) = + gmail_draft_send_cleanup_move(gmail_draft_send_cleanup_plan, entity_id) + .unwrap_or((new_parent_id, projected_path)); + let source_removed = remove_stale_gmail_draft_send_move_source_path( + self.store, + self.state_root.as_deref(), + &mount, + request.mount_id, + entity_id, + projected_path, + cleanup_projected_path, + &request.plan.operations, + )?; + if source_removed { + clear_gmail_draft_send_move_mutation( + self.store, + request.mount_id, + entity_id, + cleanup_projected_path, + )?; + } else { + convert_gmail_draft_send_move_mutation_to_create( + self.store, + self.state_root.as_deref(), + &mount, + request.mount_id, + entity_id, + cleanup_parent_id, + cleanup_projected_path, + )?; + } } } for effect in request.apply_effects { @@ -4065,6 +4378,319 @@ where } } +struct CreatedEntityReconcileOperation { + title: String, + source_path: PathBuf, + parent_kind: Option, +} + +fn created_entity_reconcile_operation( + operation: Option<&PushOperation>, + allow_gmail_draft_send_move: bool, +) -> Option { + match operation? { + PushOperation::CreateEntity { + title, + source_path, + parent_kind, + .. + } => Some(CreatedEntityReconcileOperation { + title: title.clone(), + source_path: source_path.clone(), + parent_kind: parent_kind.clone(), + }), + operation @ PushOperation::MoveEntity { + new_title, + projected_path, + new_parent_kind, + .. + } if allow_gmail_draft_send_move && is_gmail_draft_send_move(operation) => { + Some(CreatedEntityReconcileOperation { + title: new_title.clone(), + source_path: projected_path.clone(), + parent_kind: Some(new_parent_kind.clone()), + }) + } + _ => None, + } +} + +fn apply_effects_have_gmail_draft_send_created_entity( + effects: &[JournalApplyEffect], + operation_index: usize, +) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + JournalApplyEffect::CreatedEntity { + operation_index: effect_index, + parent_id, + .. + } if *effect_index == operation_index && parent_id.as_str() == "gmail-folder:sent" + ) + }) +} + +fn apply_effects_have_archived_entity( + effects: &[JournalApplyEffect], + operation_index: usize, + entity_id: &RemoteId, +) -> bool { + effects.iter().any(|effect| { + matches!( + effect, + JournalApplyEffect::ArchivedEntity { + operation_index: effect_index, + entity_id: effect_entity_id, + .. + } if *effect_index == operation_index && effect_entity_id == entity_id + ) + }) +} + +fn gmail_draft_send_cleanup_move<'a>( + plan: &'a PushPlan, + entity_id: &RemoteId, +) -> Option<(&'a RemoteId, &'a Path)> { + plan.operations + .iter() + .find_map(|operation| match operation { + operation @ PushOperation::MoveEntity { + entity_id: operation_entity_id, + new_parent_id, + projected_path, + .. + } if operation_entity_id == entity_id && is_gmail_draft_send_move(operation) => { + Some((new_parent_id, projected_path.as_path())) + } + _ => None, + }) +} + +fn remove_stale_gmail_draft_send_move_source_path( + store: &S, + state_root: Option<&Path>, + mount: &MountConfig, + mount_id: &MountId, + entity_id: &RemoteId, + planned_source_path: &Path, + current_source_path: &Path, + operations: &[PushOperation], +) -> LocalityResult +where + S: ShadowRepository, +{ + let stale_path = projection_write_path(state_root, mount, current_source_path); + if !gmail_draft_send_source_matches_plan( + store, + mount, + mount_id, + entity_id, + planned_source_path, + current_source_path, + &stale_path, + operations, + ) { + return Ok(false); + } + + match std::fs::remove_file(&stale_path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(error.into()), + } +} + +fn gmail_draft_send_source_matches_plan( + store: &S, + mount: &MountConfig, + mount_id: &MountId, + entity_id: &RemoteId, + planned_source_path: &Path, + current_source_path: &Path, + stale_path: &Path, + operations: &[PushOperation], +) -> bool +where + S: ShadowRepository, +{ + let Ok(contents) = std::fs::read_to_string(stale_path) else { + return true; + }; + let Ok(parsed) = parse_canonical_markdown(&contents) else { + return false; + }; + let Ok(shadow) = store.load_shadow(mount_id, entity_id) else { + return false; + }; + let shadow_markdown = render_canonical_markdown(&CanonicalDocument::new( + shadow.frontmatter.clone(), + shadow.rendered_body.clone(), + )); + let Ok(shadow_parsed) = parse_canonical_markdown(&shadow_markdown) else { + return false; + }; + + let mut expected_title = create_entity_title(planned_source_path, &shadow_parsed, mount); + let mut expected_properties = + frontmatter_properties_as_property_values(&shadow_parsed.frontmatter.properties); + let mut expected_null_deleted_properties = BTreeSet::new(); + let mut expected_body = shadow_parsed.document.body; + for operation in operations { + match operation { + PushOperation::MoveEntity { + entity_id: operation_entity_id, + new_title, + .. + } if operation_entity_id == entity_id => { + expected_title.clone_from(new_title); + } + PushOperation::UpdateProperties { + entity_id: operation_entity_id, + properties, + } if operation_entity_id == entity_id => { + apply_property_updates( + &mut expected_properties, + &mut expected_null_deleted_properties, + properties, + ); + } + PushOperation::UpdateEntityBody { + entity_id: operation_entity_id, + body, + } if operation_entity_id == entity_id => { + expected_body.clone_from(body); + } + _ => {} + } + } + let mut actual_properties = + frontmatter_properties_as_property_values(&parsed.frontmatter.properties); + actual_properties.retain(|key, value| { + !(expected_null_deleted_properties.contains(key) && matches!(value, PropertyValue::Null)) + }); + + create_entity_title(current_source_path, &parsed, mount) == expected_title + && actual_properties == expected_properties + && parsed.document.body == expected_body +} + +fn apply_property_updates( + target: &mut BTreeMap, + null_deleted_keys: &mut BTreeSet, + updates: &BTreeMap, +) { + for (key, value) in updates { + if matches!(value, PropertyValue::Null) { + target.remove(key); + null_deleted_keys.insert(key.clone()); + } else { + target.insert(key.clone(), value.clone()); + null_deleted_keys.remove(key); + } + } +} + +fn frontmatter_properties_as_property_values( + properties: &locality_core::canonical::FrontmatterProperties, +) -> BTreeMap { + properties + .iter() + .map(|(key, value)| (key.clone(), property_value_from_frontmatter(value))) + .collect() +} + +fn convert_gmail_draft_send_move_mutation_to_create( + store: &mut S, + state_root: Option<&Path>, + mount: &MountConfig, + mount_id: &MountId, + entity_id: &RemoteId, + parent_id: &RemoteId, + current_source_path: &Path, +) -> LocalityResult<()> +where + S: VirtualMutationRepository, +{ + let Some(mutation) = store + .find_virtual_mutation_by_path(mount_id, current_source_path) + .map_err(LocalityError::from)? + else { + return Ok(()); + }; + if !matches!(mutation.mutation_kind, VirtualMutationKind::Move) + || mutation.target_remote_id.as_ref() != Some(entity_id) + { + return Ok(()); + } + + let stale_path = projection_write_path(state_root, mount, current_source_path); + let title = std::fs::read_to_string(&stale_path) + .ok() + .and_then(|contents| parse_canonical_markdown(&contents).ok()) + .map(|parsed| create_entity_title(current_source_path, &parsed, mount)) + .unwrap_or_else(|| mutation.title.clone()); + let create_local_id = format!("local:gmail-outbox-resend:{}", entity_id.0); + let create_mutation = VirtualMutationRecord { + mount_id: mutation.mount_id.clone(), + local_id: create_local_id.clone(), + mutation_kind: VirtualMutationKind::Create, + target_remote_id: None, + parent_remote_id: Some(parent_id.clone()), + original_path: None, + projected_path: mutation.projected_path.clone(), + title, + content_path: Some(stale_path), + created_at: push_timestamp(), + updated_at: push_timestamp(), + }; + let mut superseded_local_ids = BTreeSet::from([ + mutation.local_id, + format!("move:{}", entity_id.0), + format!("rename:{}", entity_id.0), + ]); + superseded_local_ids.remove(&create_local_id); + for local_id in superseded_local_ids { + store + .delete_virtual_mutation(mount_id, &local_id) + .map_err(LocalityError::from)?; + } + store + .save_virtual_mutation(create_mutation) + .map_err(LocalityError::from)?; + Ok(()) +} + +fn clear_gmail_draft_send_move_mutation( + store: &mut S, + mount_id: &MountId, + entity_id: &RemoteId, + source_path: &Path, +) -> LocalityResult<()> +where + S: VirtualMutationRepository, +{ + if let Some(mutation) = store + .find_virtual_mutation_by_path(mount_id, source_path) + .map_err(LocalityError::from)? + && matches!(mutation.mutation_kind, VirtualMutationKind::Move) + && mutation.target_remote_id.as_ref() == Some(entity_id) + { + store + .delete_virtual_mutation(mount_id, &mutation.local_id) + .map_err(LocalityError::from)?; + } + for local_id in [ + format!("move:{}", entity_id.0), + format!("rename:{}", entity_id.0), + ] { + store + .delete_virtual_mutation(mount_id, &local_id) + .map_err(LocalityError::from)?; + } + Ok(()) +} + fn created_entity_reconcile_path( store: &S, mount_id: &MountId, @@ -4880,6 +5506,299 @@ fn generate_push_id() -> PushId { #[cfg(test)] mod tests { use super::*; + use locality_store::InMemoryStateStore; + + #[test] + fn gmail_draft_send_plan_match_requires_same_move_ids() { + let first = gmail_draft_send_plan(&["gmail-draft:draft-1"]); + let first_with_body_edit = PushPlan::new( + vec![RemoteId::new("gmail-draft:draft-1")], + vec![ + PushOperation::MoveEntity { + entity_id: RemoteId::new("gmail-draft:draft-1"), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send One".to_string(), + projected_path: PathBuf::from("outbox/Send One.md"), + }, + PushOperation::UpdateEntityBody { + entity_id: RemoteId::new("gmail-draft:draft-1"), + body: "Edited after apply.\n".to_string(), + }, + ], + ); + let first_and_second = + gmail_draft_send_plan(&["gmail-draft:draft-1", "gmail-draft:draft-2"]); + let first_plus_create = PushPlan::new( + vec![ + RemoteId::new("gmail-draft:draft-1"), + RemoteId::new("gmail-folder:outbox"), + ], + vec![ + PushOperation::MoveEntity { + entity_id: RemoteId::new("gmail-draft:draft-1"), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send One".to_string(), + projected_path: PathBuf::from("outbox/Send One.md"), + }, + PushOperation::CreateEntity { + parent_id: RemoteId::new("gmail-folder:outbox"), + parent_kind: Some(EntityKind::Directory), + parent_workspace: false, + title: "Extra".to_string(), + properties: BTreeMap::new(), + body: "Extra body.\n".to_string(), + source_path: PathBuf::from("outbox/Extra.md"), + }, + ], + ); + + assert!(gmail_draft_send_plans_match(&first, &first_with_body_edit)); + assert!(!gmail_draft_send_plans_match(&first, &first_and_second)); + assert!(!gmail_draft_send_plans_match(&first_and_second, &first)); + assert!(!gmail_draft_send_plans_match(&first, &first_plus_create)); + } + + #[test] + fn gmail_draft_send_source_match_treats_null_property_update_as_deletion() { + let mount_id = MountId::new("gmail-main"); + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let root = std::env::temp_dir().join(format!( + "loc-gmail-draft-null-property-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("outbox")).expect("fixture root"); + let mount = MountConfig::new(mount_id.clone(), "gmail", &root); + let stale_path = root.join("outbox/Send Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + std::fs::write( + &stale_path, + "---\ntitle: \"Send Remote Draft\"\nsubject: \"Send Remote Draft\"\nto: [\"bob@example.com\"]\n---\nEdited body before sending.\n", + ) + .expect("write stale source"); + let mut store = InMemoryStateStore::new(); + store + .save_shadow( + &mount_id, + ShadowDocument::from_synced_body( + entity_id.clone(), + "Original body.\n", + 1, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter( + "title: \"Remote Draft\"\nsubject: \"Remote Draft\"\nto: [\"ann@example.com\"]\ncc: [\"old@example.com\"]\n", + ), + ) + .expect("save shadow"); + + let matches = gmail_draft_send_source_matches_plan( + &store, + &mount, + &mount_id, + &entity_id, + source_path, + source_path, + &stale_path, + &[ + PushOperation::MoveEntity { + entity_id: entity_id.clone(), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send Remote Draft".to_string(), + projected_path: source_path.to_path_buf(), + }, + PushOperation::UpdateProperties { + entity_id: entity_id.clone(), + properties: BTreeMap::from([ + ( + "subject".to_string(), + PropertyValue::String("Send Remote Draft".to_string()), + ), + ( + "to".to_string(), + PropertyValue::List(vec!["bob@example.com".to_string()]), + ), + ("cc".to_string(), PropertyValue::Null), + ]), + }, + PushOperation::UpdateEntityBody { + entity_id: entity_id.clone(), + body: "Edited body before sending.\n".to_string(), + }, + ], + ); + + assert!(matches); + } + + #[test] + fn gmail_draft_send_source_match_treats_stale_null_property_as_deleted() { + let mount_id = MountId::new("gmail-main"); + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let root = std::env::temp_dir().join(format!( + "loc-gmail-draft-stale-null-property-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("outbox")).expect("fixture root"); + let mount = MountConfig::new(mount_id.clone(), "gmail", &root); + let stale_path = root.join("outbox/Send Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + std::fs::write( + &stale_path, + "---\ntitle: \"Send Remote Draft\"\nsubject: \"Send Remote Draft\"\nto: [\"bob@example.com\"]\ncc:\n---\nEdited body before sending.\n", + ) + .expect("write stale source"); + let mut store = InMemoryStateStore::new(); + store + .save_shadow( + &mount_id, + ShadowDocument::from_synced_body( + entity_id.clone(), + "Original body.\n", + 1, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter( + "title: \"Remote Draft\"\nsubject: \"Remote Draft\"\nto: [\"ann@example.com\"]\ncc: [\"old@example.com\"]\n", + ), + ) + .expect("save shadow"); + + let matches = gmail_draft_send_source_matches_plan( + &store, + &mount, + &mount_id, + &entity_id, + source_path, + source_path, + &stale_path, + &[ + PushOperation::MoveEntity { + entity_id: entity_id.clone(), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send Remote Draft".to_string(), + projected_path: source_path.to_path_buf(), + }, + PushOperation::UpdateProperties { + entity_id: entity_id.clone(), + properties: BTreeMap::from([ + ( + "subject".to_string(), + PropertyValue::String("Send Remote Draft".to_string()), + ), + ( + "to".to_string(), + PropertyValue::List(vec!["bob@example.com".to_string()]), + ), + ("cc".to_string(), PropertyValue::Null), + ]), + }, + PushOperation::UpdateEntityBody { + entity_id: entity_id.clone(), + body: "Edited body before sending.\n".to_string(), + }, + ], + ); + + assert!(matches); + } + + #[test] + fn gmail_draft_send_source_match_keeps_unrelated_stale_null_property() { + let mount_id = MountId::new("gmail-main"); + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let root = std::env::temp_dir().join(format!( + "loc-gmail-draft-unrelated-null-property-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("outbox")).expect("fixture root"); + let mount = MountConfig::new(mount_id.clone(), "gmail", &root); + let stale_path = root.join("outbox/Send Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + std::fs::write( + &stale_path, + "---\ntitle: \"Send Remote Draft\"\nsubject: \"Send Remote Draft\"\nto: [\"bob@example.com\"]\ncc:\n---\nEdited body before sending.\n", + ) + .expect("write stale source"); + let mut store = InMemoryStateStore::new(); + store + .save_shadow( + &mount_id, + ShadowDocument::from_synced_body( + entity_id.clone(), + "Original body.\n", + 1, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter( + "title: \"Remote Draft\"\nsubject: \"Remote Draft\"\nto: [\"ann@example.com\"]\n", + ), + ) + .expect("save shadow"); + + let matches = gmail_draft_send_source_matches_plan( + &store, + &mount, + &mount_id, + &entity_id, + source_path, + source_path, + &stale_path, + &[ + PushOperation::MoveEntity { + entity_id: entity_id.clone(), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send Remote Draft".to_string(), + projected_path: source_path.to_path_buf(), + }, + PushOperation::UpdateProperties { + entity_id: entity_id.clone(), + properties: BTreeMap::from([ + ( + "subject".to_string(), + PropertyValue::String("Send Remote Draft".to_string()), + ), + ( + "to".to_string(), + PropertyValue::List(vec!["bob@example.com".to_string()]), + ), + ]), + }, + PushOperation::UpdateEntityBody { + entity_id: entity_id.clone(), + body: "Edited body before sending.\n".to_string(), + }, + ], + ); + + assert!(!matches); + } + + fn gmail_draft_send_plan(ids: &[&str]) -> PushPlan { + PushPlan::new( + ids.iter().map(|id| RemoteId::new(*id)).collect(), + ids.iter() + .enumerate() + .map(|(index, id)| PushOperation::MoveEntity { + entity_id: RemoteId::new(*id), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: format!("Send {}", index + 1), + projected_path: PathBuf::from(format!("outbox/Send {}.md", index + 1)), + }) + .collect(), + ) + } #[cfg(target_os = "macos")] #[test] diff --git a/crates/localityd/src/reconcile.rs b/crates/localityd/src/reconcile.rs index 3c0d9421..b4769539 100644 --- a/crates/localityd/src/reconcile.rs +++ b/crates/localityd/src/reconcile.rs @@ -5,10 +5,12 @@ //! decision by enumerating, refreshing local projections, and queueing hydration. use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use locality_core::canonical::{parse_canonical_markdown, render_canonical_markdown}; +use locality_core::canonical::{ + ParsedCanonicalDocument, parse_canonical_markdown, render_canonical_markdown, +}; use locality_core::freshness::{FreshnessTier, RemoteVersion}; use locality_core::hydration::{ HydrationPolicy, HydrationReason, HydrationRequest, should_eager_hydrate, @@ -25,8 +27,12 @@ use locality_store::{ use crate::hydration::HydrationEngine; use crate::scheduler::PullSchedulerTick; +use crate::shadow_match::parsed_matches_shadow; use crate::virtual_fs::{repair_legacy_macos_content_root, virtual_fs_content_root}; +const GMAIL_CONNECTOR_ID: &str = "gmail"; +const GMAIL_DRAFT_REMOTE_PREFIX: &str = "gmail-draft:"; + pub trait ScheduledPullSource { fn enumerate_mount(&self, mount: &MountConfig) -> LocalityResult>; @@ -131,7 +137,11 @@ pub fn reconcile_scheduled_pull( policy: &HydrationPolicy, ) -> LocalityResult where - S: EntityRepository + RemoteObservationRepository + FreshnessStateRepository, + S: EntityRepository + + RemoteObservationRepository + + FreshnessStateRepository + + locality_store::HydrationJobRepository + + locality_store::ShadowRepository, H: HydrationEngine, Source: ScheduledPullSource + ?Sized, Strategy: FetchScheduleStrategy + ?Sized, @@ -153,7 +163,11 @@ pub fn reconcile_scheduled_pull_with_state_root( state_root: Option<&Path>, ) -> LocalityResult where - S: EntityRepository + RemoteObservationRepository + FreshnessStateRepository, + S: EntityRepository + + RemoteObservationRepository + + FreshnessStateRepository + + locality_store::HydrationJobRepository + + locality_store::ShadowRepository, H: HydrationEngine, Source: ScheduledPullSource + ?Sized, Strategy: FetchScheduleStrategy + ?Sized, @@ -205,16 +219,19 @@ where path: record.path.clone(), ..entry.clone() }; - store.save_entity(record)?; + let skip_projection_refresh = + save_entity_after_gmail_draft_repair(store, mount, &projected_entry, record)?; rename_projection_if_needed(mount, existing.as_ref(), &projected_entry)?; - match refresh_projection(source, mount, &projected_entry, state_root)? { - ProjectionWrite::Stub => report.stubbed += 1, - ProjectionWrite::Schema => report.schemas_written += 1, - ProjectionWrite::None => {} + if !skip_projection_refresh { + match refresh_projection(source, mount, &projected_entry, state_root)? { + ProjectionWrite::Stub => report.stubbed += 1, + ProjectionWrite::Schema => report.schemas_written += 1, + ProjectionWrite::None => {} + } } - if let Some(reason) = entity_plan.queue_hydration { + if !skip_projection_refresh && let Some(reason) = entity_plan.queue_hydration { hydration.queue(HydrationRequest::new( mount.mount_id.clone(), projected_entry.remote_id.clone(), @@ -325,6 +342,164 @@ fn merged_entity_record( record } +fn save_entity_after_gmail_draft_repair( + store: &mut S, + mount: &MountConfig, + entry: &TreeEntry, + record: EntityRecord, +) -> LocalityResult +where + S: EntityRepository + + RemoteObservationRepository + + FreshnessStateRepository + + locality_store::HydrationJobRepository + + locality_store::ShadowRepository, +{ + let replacement_frontmatter = gmail_draft_replacement_frontmatter(entry); + let repair = crate::gmail::repair_legacy_gmail_draft_message_id_collision( + store, + mount, + &record, + Some(&replacement_frontmatter), + )?; + store.save_entity(record)?; + let skip_projection_refresh = repair_gmail_draft_projection_after_identity_repair( + mount, + entry, + &replacement_frontmatter, + &repair, + )?; + Ok(skip_projection_refresh) +} + +fn gmail_draft_replacement_frontmatter(entry: &TreeEntry) -> String { + entry + .stub_frontmatter + .clone() + .unwrap_or_else(|| stub_frontmatter(entry)) +} + +fn repair_gmail_draft_projection_after_identity_repair( + mount: &MountConfig, + entry: &TreeEntry, + replacement_frontmatter: &str, + repair: &crate::gmail::GmailDraftIdentityRepair, +) -> LocalityResult { + if mount.projection.uses_virtual_filesystem() { + return Ok(false); + } + if entry.kind != EntityKind::Page { + return Ok(false); + } + + let path = mount.root.join(&entry.path); + if !path.exists() { + return Ok(false); + } + let contents = read_to_string(&path)?; + let parsed = match parse_canonical_markdown(&contents) { + Ok(parsed) => parsed, + Err(_) => return Ok(false), + }; + if parsed.document.is_stub() { + let Some(legacy_remote_id) = legacy_stub_remote_id(mount, entry, &parsed, repair) else { + return Ok(false); + }; + if legacy_stub_frontmatter_has_no_local_drift( + &parsed, + entry, + replacement_frontmatter, + legacy_remote_id, + ) { + write_atomic(&path, stub_markdown(entry)?)?; + return Ok(false); + } + return Ok(true); + } + + if repair.retired_legacy.is_none() { + return Ok(false); + } + + let Some(retired_shadow) = repair.retired_shadow.as_ref() else { + return Ok(false); + }; + if !parsed_matches_shadow(&parsed, retired_shadow) { + return Ok(false); + } + + write_atomic( + &path, + render_canonical_markdown(&CanonicalDocument::new( + replacement_frontmatter.to_string(), + parsed.document.body, + )), + )?; + Ok(false) +} + +fn legacy_stub_remote_id<'a>( + mount: &MountConfig, + entry: &TreeEntry, + parsed: &'a ParsedCanonicalDocument, + repair: &'a crate::gmail::GmailDraftIdentityRepair, +) -> Option<&'a RemoteId> { + if let Some(legacy) = repair.retired_legacy.as_ref() { + return Some(&legacy.remote_id); + } + + if !is_gmail_draft_projection_repair_candidate(mount, entry) { + return None; + } + let remote_id = parsed.remote_id()?; + if remote_id == &entry.remote_id || remote_id.as_str().contains(':') { + return None; + } + Some(remote_id) +} + +fn legacy_stub_frontmatter_has_no_local_drift( + parsed: &ParsedCanonicalDocument, + entry: &TreeEntry, + replacement_frontmatter: &str, + legacy_remote_id: &RemoteId, +) -> bool { + if parsed.remote_id() != Some(legacy_remote_id) { + return false; + } + + let expected = render_canonical_markdown(&CanonicalDocument::new( + replacement_frontmatter.to_string(), + format!("{}\n", CanonicalDocument::STUB_MARKER), + )); + let Ok(expected) = parse_canonical_markdown(&expected) else { + return false; + }; + + let mut actual_frontmatter = parsed.frontmatter.clone(); + let Some(loc) = actual_frontmatter.loc.as_mut() else { + return false; + }; + loc.id = Some(entry.remote_id.clone()); + actual_frontmatter == expected.frontmatter +} + +fn is_gmail_draft_projection_repair_candidate(mount: &MountConfig, entry: &TreeEntry) -> bool { + mount.connector == GMAIL_CONNECTOR_ID + && entry + .remote_id + .as_str() + .starts_with(GMAIL_DRAFT_REMOTE_PREFIX) + && is_direct_gmail_draft_path(&entry.path) +} + +fn is_direct_gmail_draft_path(path: &Path) -> bool { + let mut components = path.components(); + matches!(components.next(), Some(Component::Normal(component)) if component == "draft") + && matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() +} + #[derive(Debug, Default)] struct RemoteMovePlan { preserve_remote_ids: BTreeSet, @@ -691,11 +866,309 @@ fn read_to_string(path: &Path) -> LocalityResult { #[cfg(test)] mod tests { use super::*; + use locality_core::model::MountId; + use locality_core::shadow::ShadowDocument; + use locality_store::{EntityRepository, InMemoryStateStore, MountRepository, ShadowRepository}; + + struct NoopHydrationEngine; + + impl HydrationEngine for NoopHydrationEngine { + fn queue(&mut self, _request: HydrationRequest) -> LocalityResult<()> { + Ok(()) + } + + fn drain_ready(&mut self) -> LocalityResult { + Ok(0) + } + } + + struct StaticScheduledSource { + entries: Vec, + } + + impl ScheduledPullSource for StaticScheduledSource { + fn enumerate_mount(&self, _mount: &MountConfig) -> LocalityResult> { + Ok(self.entries.clone()) + } + } + + #[test] + fn scheduled_pull_gmail_draft_repairs_legacy_message_id_entity_collision() { + let mount_id = MountId::new("gmail-main"); + let root = std::env::temp_dir().join(format!( + "loc-scheduled-gmail-draft-repair-{}", + std::process::id() + )); + let mount = MountConfig::new(mount_id.clone(), "gmail", &root); + let draft_path = PathBuf::from("draft/1720900000000-hello-draft-msg-1.md"); + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let mut store = InMemoryStateStore::new(); + store.save_mount(mount.clone()).expect("save mount"); + store + .save_entity( + EntityRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + &draft_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save legacy draft"); + let source = StaticScheduledSource { + entries: vec![TreeEntry { + mount_id: mount_id.clone(), + remote_id: draft_remote_id.clone(), + kind: EntityKind::Page, + title: "Hello".to_string(), + path: draft_path.clone(), + hydration: HydrationState::Stub, + content_hash: None, + remote_edited_at: None, + stub_frontmatter: None, + }], + }; + let mut hydration = NoopHydrationEngine; + let tick = PullSchedulerTick { + poll_active: true, + poll_cold: false, + }; + + let report = reconcile_scheduled_pull( + &mut store, + &mut hydration, + &[mount], + &tick, + &source, + &DefaultFetchScheduleStrategy, + &HydrationPolicy::default(), + ) + .expect("scheduled reconcile"); + + assert_eq!(report.enumerated, 1); + assert!( + store + .get_entity(&mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert_eq!( + store + .get_entity(&mount_id, &draft_remote_id) + .expect("draft lookup") + .expect("draft entity") + .path, + draft_path + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn scheduled_pull_gmail_draft_repairs_clean_legacy_projection_identity() { + let mount_id = MountId::new("gmail-main"); + let root = std::env::temp_dir().join(format!( + "loc-scheduled-gmail-draft-projection-repair-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let mount = MountConfig::new(mount_id.clone(), "gmail", &root); + let draft_path = PathBuf::from("draft/1720900000000-hello-draft-msg-1.md"); + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let old_frontmatter = gmail_draft_frontmatter(&legacy_message_id); + let new_frontmatter = gmail_draft_frontmatter(&draft_remote_id); + let body = "A clean scheduled draft body.\n"; + let mut store = InMemoryStateStore::new(); + store.save_mount(mount.clone()).expect("save mount"); + write_atomic( + &root.join(&draft_path), + render_canonical_markdown(&CanonicalDocument::new( + old_frontmatter.clone(), + body.to_string(), + )), + ) + .expect("write legacy projection"); + store + .save_entity( + EntityRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + &draft_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save legacy draft"); + store + .save_shadow( + &mount_id, + shadow_document(&legacy_message_id, &old_frontmatter, body), + ) + .expect("save legacy shadow"); + let source = StaticScheduledSource { + entries: vec![TreeEntry { + mount_id: mount_id.clone(), + remote_id: draft_remote_id.clone(), + kind: EntityKind::Page, + title: "Hello".to_string(), + path: draft_path.clone(), + hydration: HydrationState::Stub, + content_hash: None, + remote_edited_at: None, + stub_frontmatter: Some(new_frontmatter.clone()), + }], + }; + let mut hydration = NoopHydrationEngine; + let tick = PullSchedulerTick { + poll_active: true, + poll_cold: false, + }; + + let report = reconcile_scheduled_pull( + &mut store, + &mut hydration, + &[mount], + &tick, + &source, + &DefaultFetchScheduleStrategy, + &HydrationPolicy::default(), + ) + .expect("scheduled reconcile"); + + assert_eq!(report.enumerated, 1); + let rewritten = read_to_string(&root.join(&draft_path)).expect("read projection"); + let parsed = parse_canonical_markdown(&rewritten).expect("parse projection"); + assert_eq!(parsed.remote_id(), Some(&draft_remote_id)); + assert_eq!(parsed.document.frontmatter, new_frontmatter); + assert_eq!(parsed.document.body, body); + assert!( + store + .get_entity(&mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert!( + store + .get_shadow_record(&mount_id, &legacy_message_id) + .expect("legacy shadow lookup") + .is_none() + ); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn scheduled_pull_gmail_draft_does_not_rewrite_frontmatter_edited_stub_projection() { + let mount_id = MountId::new("gmail-main"); + let root = std::env::temp_dir().join(format!( + "loc-scheduled-gmail-draft-stub-drift-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let mount = MountConfig::new(mount_id.clone(), "gmail", &root); + let draft_path = PathBuf::from("draft/1720900000000-hello-draft-msg-1.md"); + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let edited_frontmatter = gmail_draft_frontmatter(&legacy_message_id) + .replace("subject: Hello\n", "subject: Edited locally\n"); + let locally_edited_stub = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + format!("{}\n", CanonicalDocument::STUB_MARKER), + )); + let mut store = InMemoryStateStore::new(); + store.save_mount(mount.clone()).expect("save mount"); + write_atomic(&root.join(&draft_path), locally_edited_stub.clone()) + .expect("write frontmatter-edited stub"); + store + .save_entity( + EntityRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + &draft_path, + ) + .with_hydration(HydrationState::Stub), + ) + .expect("save legacy draft"); + let source = StaticScheduledSource { + entries: vec![TreeEntry { + mount_id: mount_id.clone(), + remote_id: draft_remote_id.clone(), + kind: EntityKind::Page, + title: "Hello".to_string(), + path: draft_path.clone(), + hydration: HydrationState::Stub, + content_hash: None, + remote_edited_at: None, + stub_frontmatter: Some(gmail_draft_frontmatter(&draft_remote_id)), + }], + }; + let mut hydration = NoopHydrationEngine; + let tick = PullSchedulerTick { + poll_active: true, + poll_cold: false, + }; + let mounts = vec![mount]; + let policy = HydrationPolicy { + eager_under_page_count: Some(10), + ..HydrationPolicy::default() + }; + + let report = reconcile_scheduled_pull( + &mut store, + &mut hydration, + &mounts, + &tick, + &source, + &DefaultFetchScheduleStrategy, + &policy, + ) + .expect("scheduled reconcile"); + + assert_eq!(report.enumerated, 1); + assert_eq!(report.queued_hydrations, 0); + assert_eq!( + read_to_string(&root.join(&draft_path)).expect("read projection"), + locally_edited_stub + ); + assert!( + store + .get_entity(&mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert!( + store + .get_entity(&mount_id, &draft_remote_id) + .expect("draft lookup") + .is_some() + ); + let report = reconcile_scheduled_pull( + &mut store, + &mut hydration, + &mounts, + &tick, + &source, + &DefaultFetchScheduleStrategy, + &policy, + ) + .expect("second scheduled reconcile"); + + assert_eq!(report.enumerated, 1); + assert_eq!(report.queued_hydrations, 0); + assert_eq!( + read_to_string(&root.join(&draft_path)).expect("read projection after second pass"), + locally_edited_stub + ); + let _ = std::fs::remove_dir_all(root); + } #[cfg(target_os = "macos")] #[test] fn virtual_schema_refresh_repairs_legacy_app_group_cache_before_write() { - use locality_core::model::MountId; use locality_store::ProjectionMode; struct SchemaSource; @@ -759,4 +1232,22 @@ mod tests { ); let _ = std::fs::remove_dir_all(home); } + + fn gmail_draft_frontmatter(remote_id: &RemoteId) -> String { + format!( + "loc:\n id: \"{}\"\n type: page\n connector: gmail\n synced_at: \"2026-06-11T00:00:00.000Z\"\n remote_edited_at: \"2026-06-11T00:00:00.000Z\"\ntitle: Hello\nsubject: Hello\nto: [\"ann@example.com\"]\ngmail:\n mailbox: draft\n message_count: 1\n", + remote_id.0 + ) + } + + fn shadow_document(remote_id: &RemoteId, frontmatter: &str, body: &str) -> ShadowDocument { + ShadowDocument::from_synced_body( + remote_id.clone(), + body.to_string(), + frontmatter.lines().count() + 3, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter(frontmatter.to_string()) + } } diff --git a/crates/localityd/src/source.rs b/crates/localityd/src/source.rs index d0e82e9f..7b46017e 100644 --- a/crates/localityd/src/source.rs +++ b/crates/localityd/src/source.rs @@ -360,8 +360,12 @@ pub fn source_move_decision_for_parent_path( return decision; } if mount.connector == "gmail" { - return SourceWriteDecision::ReadOnly { - reason: "Gmail moves are not supported; create a new file directly under draft/ or outbox/", + return if parent_path.components().count() == 1 && parent_path == Path::new("outbox") { + SourceWriteDecision::Writable + } else { + SourceWriteDecision::ReadOnly { + reason: "Gmail only supports moving an existing draft directly into outbox/ to send it", + } }; } if mount.connector == LINEAR_CONNECTOR_ID { @@ -494,8 +498,8 @@ fn gmail_source_descriptor() -> SourceDescriptor { create_entity_parent_kinds: vec![EntityKind::Directory], move_entity_parent_kinds: vec![EntityKind::Directory], periodic_discovery_interval: None, - body_diff_mode: BodyDiffMode::Block, - virtual_rename_policy: VirtualRenamePolicy::FilenameDerived, + body_diff_mode: BodyDiffMode::WholeEntity, + virtual_rename_policy: VirtualRenamePolicy::PreserveCanonical, max_background_discovery_workers: 4, } } @@ -785,14 +789,16 @@ fn gmail_mount_guidance() -> String { Gmail facts:\n\ - This mount projects Gmail inbox/, sent/, draft/, and outbox/ folders.\n\ - inbox/ and sent/ are read-only mailbox history.\n\ +- draft/ contains remote Gmail drafts and local draft creates. Edit a remote draft there and push to update the Gmail draft.\n\ - Create a Markdown file directly under draft/ to create an unsent Gmail draft.\n\ -- Create a Markdown file directly under outbox/ to send immediately after explicit review and push.\n\ +- outbox/ is local-only send staging. Create a Markdown file directly under outbox/ to send immediately after explicit review and push.\n\ +- Move an existing remote draft from draft/ to outbox/ and push to send the updated draft.\n\ - Both outbound folders require `to` frontmatter and either `subject` or `title` frontmatter.\n\ -- Use outbox/ only when the user explicitly asks to send mail now; otherwise use draft/ for review in Gmail.\n\ +- Use outbox/ only when the user explicitly asks to send mail now; otherwise leave messages in draft/ for drafting and revision.\n\ - To inspect inbound attachments, first hydrate the message or thread Markdown by opening it or running `loc pull `.\n\ - Hydrated messages list attachments in YAML frontmatter under `gmail.attachments`; read `filename`, `mime_type`, `size`, `attachment_id`, and `path` from that list.\n\ - Open the attachment file at the listed `path`, relative to the mount root. Gmail attachment caches normally live under `.loc/gmail/attachments/...`; use the frontmatter path exactly.\n\ -- Gmail draft creation does not support outbound attachments yet. Outbox direct-send creation does not support outbound attachments yet either. Do not add `attachment` or `attachments` frontmatter to draft or outbox files.\n", +- Gmail outbound attachments are not supported yet. Do not add `attachment` or `attachments` frontmatter to draft or outbox files.\n", generic_mount_guidance("Gmail") ) } diff --git a/crates/localityd/src/supervisor.rs b/crates/localityd/src/supervisor.rs index 7fbf798b..6182259c 100644 --- a/crates/localityd/src/supervisor.rs +++ b/crates/localityd/src/supervisor.rs @@ -9,8 +9,8 @@ use locality_core::journal::JournalStore; use locality_core::model::{EntityKind, HydrationState}; use locality_store::{ AutoSaveRepository, EntityRecord, EntityRepository, FreshnessStateRepository, - JournalRepository, MountConfig, MountRepository, ProjectionMode, RemoteObservationRepository, - ShadowRepository, VirtualMutationRepository, + HydrationJobRepository, JournalRepository, MountConfig, MountRepository, ProjectionMode, + RemoteObservationRepository, ShadowRepository, VirtualMutationRepository, }; use crate::execution::{ @@ -252,6 +252,7 @@ where + EntityRepository + RemoteObservationRepository + FreshnessStateRepository + + HydrationJobRepository + ShadowRepository + JournalRepository + JournalStore diff --git a/crates/localityd/src/virtual_fs.rs b/crates/localityd/src/virtual_fs.rs index 141dba71..d6aa7082 100644 --- a/crates/localityd/src/virtual_fs.rs +++ b/crates/localityd/src/virtual_fs.rs @@ -19,8 +19,9 @@ use locality_core::path_projection::{ }; use locality_core::{LocalityError, LocalityResult}; use locality_store::{ - EntityRecord, EntityRepository, FreshnessStateRecord, FreshnessStateRepository, MountConfig, - MountRepository, ProjectionMode, ShadowRepository, StoreError, VirtualMoveRepository, + EntityRecord, EntityRepository, FreshnessStateRecord, FreshnessStateRepository, + HydrationJobRepository, MountConfig, MountRepository, ProjectionMode, + RemoteObservationRepository, ShadowRepository, StoreError, VirtualMoveRepository, VirtualMoveTransition, VirtualMutationKind, VirtualMutationRecord, VirtualMutationRepository, }; use serde::{Deserialize, Serialize}; @@ -499,7 +500,12 @@ pub fn refresh_virtual_fs_children( container_identifier: &str, ) -> LocalityResult where - S: MountRepository + EntityRepository, + S: MountRepository + + EntityRepository + + ShadowRepository + + HydrationJobRepository + + FreshnessStateRepository + + RemoteObservationRepository, C: Connector + ?Sized, { let mount = require_virtual_mount(store, mount_id)?; @@ -534,10 +540,21 @@ where let existing = store .get_entity(&entry.mount_id, &entry.remote_id) .map_err(LocalityError::from)?; - let record = refreshed_entity_record(entry, existing.as_ref()); + let record = refreshed_entity_record(entry.clone(), existing.as_ref()); if existing.as_ref() != Some(&record) { changed = true; } + let replacement_frontmatter = entry + .stub_frontmatter + .clone() + .unwrap_or_else(|| virtual_fs_stub_frontmatter(&entry)); + crate::gmail::repair_legacy_gmail_draft_message_id_collision( + store, + &mount, + &record, + Some(&replacement_frontmatter), + ) + .map_err(LocalityError::from)?; store.save_entity(record).map_err(LocalityError::from)?; saved += 1; } @@ -545,6 +562,27 @@ where Ok(VirtualFsRefreshChildrenReport { saved, changed }) } +fn virtual_fs_stub_frontmatter(entry: &locality_core::model::TreeEntry) -> String { + format!( + "loc:\n id: {}\n type: {}\n synced_at: {}\n remote_edited_at: {}\ntitle: {}\n", + entry.remote_id.0, + virtual_fs_entity_type_name(&entry.kind), + yaml_string(entry.remote_edited_at.as_deref().unwrap_or("unknown")), + yaml_string(entry.remote_edited_at.as_deref().unwrap_or("unknown")), + yaml_string(&entry.title) + ) +} + +fn virtual_fs_entity_type_name(kind: &EntityKind) -> &'static str { + match kind { + EntityKind::Page => "page", + EntityKind::Database => "database", + EntityKind::Directory => "directory", + EntityKind::Asset => "asset", + EntityKind::Unknown(_) => "unknown", + } +} + pub(crate) fn prune_stale_virtual_children( store: &mut S, mount_id: &MountId, @@ -600,7 +638,12 @@ pub fn refresh_virtual_fs_children_with_content_root( container_identifier: &str, ) -> LocalityResult where - S: MountRepository + EntityRepository, + S: MountRepository + + EntityRepository + + ShadowRepository + + HydrationJobRepository + + FreshnessStateRepository + + RemoteObservationRepository, C: Connector + HydrationSource + ?Sized, { let _ = require_virtual_mount(store, mount_id)?; @@ -6155,6 +6198,74 @@ mod tests { assert_eq!(report.item.entity_kind, Some(EntityKind::Page)); } + #[test] + fn refresh_children_repairs_legacy_gmail_draft_message_id_collision() { + let mount_id = MountId::new("gmail-main"); + let mut store = InMemoryStateStore::new(); + let mount = virtual_mount_with_connector(&mount_id, "gmail"); + store.save_mount(mount).expect("save mount"); + store + .save_entity(EntityRecord::new( + mount_id.clone(), + RemoteId::new("gmail-folder:draft"), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + let draft_path = "draft/1720900000000-hello-draft-msg-1.md"; + let legacy_message_id = RemoteId::new("draft-msg-1"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + store + .save_entity( + EntityRecord::new( + mount_id.clone(), + legacy_message_id.clone(), + EntityKind::Page, + "Hello", + draft_path, + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save legacy draft"); + let connector = StaticChildrenConnector { + entries: vec![TreeEntry { + mount_id: mount_id.clone(), + remote_id: draft_remote_id.clone(), + kind: EntityKind::Page, + title: "Hello".to_string(), + path: draft_path.into(), + hydration: HydrationState::Stub, + content_hash: None, + remote_edited_at: None, + stub_frontmatter: None, + }], + expected_parent_path: PathBuf::from("draft"), + database_schema: None, + complete: false, + }; + + let report = + refresh_virtual_fs_children(&mut store, &connector, &mount_id, "gmail-folder:draft") + .expect("refresh draft children"); + + assert_eq!(report.saved, 1); + assert!( + store + .get_entity(&mount_id, &legacy_message_id) + .expect("legacy lookup") + .is_none() + ); + assert_eq!( + store + .get_entity(&mount_id, &draft_remote_id) + .expect("draft lookup") + .expect("draft entity") + .path, + PathBuf::from(draft_path) + ); + } + #[test] fn gmail_outbox_folder_accepts_virtual_create() { let mount_id = MountId::new("gmail-main"); diff --git a/crates/localityd/tests/push_execution.rs b/crates/localityd/tests/push_execution.rs index 390a8ddc..4a3e62b0 100644 --- a/crates/localityd/tests/push_execution.rs +++ b/crates/localityd/tests/push_execution.rs @@ -5,6 +5,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; +use base64::Engine as _; +use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; use locality_connector::{ ApplyPlanRequest, ApplyPlanResult, ApplyUndoRequest, ApplyUndoResult, Connector, ConnectorCapabilities, ConnectorKind, EnumerateRequest, FetchRequest, NativeEntity, @@ -21,6 +23,13 @@ use locality_core::planner::{PropertyValue, PushOperation, PushOperationKind, Pu use locality_core::push::PushExecutionAction; use locality_core::shadow::ShadowDocument; use locality_core::{LocalityError, LocalityResult}; +use locality_gmail::client::GmailApi; +use locality_gmail::dto::{ + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailDraftSendRequest, + GmailDraftUpdateRequest, GmailHeader, GmailMessage, GmailMessageList, GmailMessagePart, + GmailMessagePartBody, GmailMessageSendRequest, GmailThread, GmailThreadList, +}; +use locality_gmail::{GmailConfig, GmailConnector}; use locality_linear::{ LinearApi, LinearConfig, LinearConnector, LinearIssue, LinearIssuePage, LinearIssuePriority, LinearIssueState, LinearIssueUpdateInput, LinearLabel, LinearProject, LinearTeam, LinearUser, @@ -35,7 +44,7 @@ use locality_notion::{NotionConfig, NotionConnector}; use locality_store::{ AutoSaveEnrollmentRecord, AutoSaveOrigin, AutoSaveRepository, AutoSaveState, EntityRecord, EntityRepository, InMemoryStateStore, JournalRepository, MountConfig, MountRepository, - ProjectionMode, ShadowRepository, VirtualMutationKind, VirtualMutationRecord, + ProjectionMode, ShadowRepository, SqliteStateStore, VirtualMutationKind, VirtualMutationRecord, VirtualMutationRepository, }; use localityd::execution::{DaemonExecutor, PushJob}; @@ -1242,317 +1251,669 @@ fn daemon_push_reconciles_gmail_send_create_to_sent_folder() { } #[test] -fn daemon_push_reconciles_google_calendar_draft_create_to_canonical_event_filename() { +fn daemon_push_reconciles_gmail_draft_update() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); - let source_path = Path::new("draft/design-review.md"); + let source_path = Path::new("draft/Remote Draft.md"); let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let api = Arc::new(RecordingGmailApi::new()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let mut store = gmail_draft_store(&fixture, &connector, &draft_remote_id, source_path); + let rendered = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load draft shadow"); + let edited_frontmatter = rendered + .frontmatter + .replace("title: \"Remote Draft\"", "title: \"Updated Draft\"") + .replace("to: [\"ann@example.com\"]", "to: [\"bob@example.com\"]") + .replace("subject: \"Remote Draft\"", "subject: \"Updated Draft\""); + let edited = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + "Updated body.\n".to_string(), + )); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); - fs::write( - &cache_path, - "---\ntitle: Design review\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", - ) - .expect("cache file"); - - let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); - let events_folder_id = RemoteId::new("google-calendar-folder:events"); - let created_remote_id = RemoteId::new("google-calendar-event:primary:created-event"); - let expected_path = PathBuf::from("events/20260720-100000-design-review-created-event.md"); - let mut store = InMemoryStateStore::new(); - store - .save_mount( - MountConfig::new(fixture.mount_id.clone(), "google-calendar", &fixture.root) - .projection(ProjectionMode::LinuxFuse), - ) - .expect("save mount"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - draft_folder_id.clone(), - EntityKind::Directory, - "draft", - "draft", - )) - .expect("save draft folder"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - events_folder_id.clone(), - EntityKind::Directory, - "events", - "events", - )) - .expect("save events folder"); - store - .save_virtual_mutation(virtual_mutation( - &fixture.mount_id, - "local:calendar-draft", - VirtualMutationKind::Create, - None, - Some(draft_folder_id), - "draft/design-review.md", - Some(cache_path), - )) - .expect("save mutation"); - let source = FakePushSource::default() - .with_created_entity( - created_remote_id.clone(), - rendered_google_calendar_entity( - "google-calendar-event:primary:created-event", - "Design review", - "2026-07-20T10:00:00-07:00", - "Agenda", - ), - ) - .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-calendar-draft".to_string()), - operation_index: 0, - parent_id: events_folder_id, - entity_id: created_remote_id.clone(), - }]); + fs::write(&cache_path, &edited).expect("edit draft"); let report = execute_push_job_with_content_root( &mut store, PushJob { target_path: fixture.root.join(source_path), assume_yes: true, - confirm_dangerous: false, + confirm_dangerous: true, }, - &source, + &connector, Some(&state_root), ) - .expect("push google calendar draft"); + .expect("push draft update"); assert_eq!(report.action, PushJobAction::Reconciled); - let event = store - .get_entity(&fixture.mount_id, &created_remote_id) - .expect("get created event") - .expect("created event entity"); - assert_eq!(event.path, expected_path); - let requested_paths = source.requested_paths(); + let calls = api.calls(); + assert_eq!(calls.updated_drafts.len(), 1); + assert_eq!(calls.updated_drafts[0].0, "draft-1"); + assert!(decode_raw_mime(&calls.updated_drafts[0].1).contains("Updated body.")); + assert!(calls.sent_drafts.is_empty()); + let entity = store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("get draft") + .expect("draft entity"); + assert_eq!(entity.remote_id, draft_remote_id); + assert_eq!(entity.path, PathBuf::from("draft/Remote Draft.md")); + assert!(content_root.join(source_path).exists()); + let shadow = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load reconciled shadow"); + let projected = fs::read_to_string(content_root.join(source_path)).expect("projected draft"); assert_eq!( - requested_paths, - vec![ - PathBuf::from("events/design-review.md"), - expected_path.clone() - ] - ); - assert_eq!(requested_paths.last(), Some(&expected_path)); - assert!(content_root.join(&expected_path).exists()); - assert!(!content_root.join(source_path).exists()); - assert!( - store - .find_virtual_mutation_by_path(&fixture.mount_id, source_path) - .expect("find mutation") - .is_none() + render_canonical_markdown(&CanonicalDocument::new( + shadow.frontmatter.clone(), + shadow.rendered_body.clone(), + )), + projected ); + assert!(projected.contains("subject: \"Updated Draft\"")); + assert!(projected.contains("to: [\"bob@example.com\"]")); + assert!(projected.contains("Updated body.")); } #[test] -fn daemon_push_reconciles_long_google_calendar_event_id_to_filesystem_safe_filename() { +fn daemon_push_reconciles_gmail_draft_move_to_outbox_send() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); - let source_path = Path::new("draft/design-review.md"); + let original_path = Path::new("draft/Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let sent_remote_id = RemoteId::new("gmail-message:sent-1"); + let api = Arc::new(RecordingGmailApi::new()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let mut store = gmail_draft_store(&fixture, &connector, &draft_remote_id, original_path); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Send Remote Draft", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at("gmail:draft-message-1:1720900000000:DRAFT"), + ) + .expect("save moved draft entity"); + let rendered = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load draft shadow"); + let edited_frontmatter = rendered + .frontmatter + .replace("title: \"Remote Draft\"", "title: \"Send Remote Draft\"") + .replace("to: [\"ann@example.com\"]", "to: [\"bob@example.com\"]") + .replace( + "subject: \"Remote Draft\"", + "subject: \"Send Remote Draft\"", + ); + let edited = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + "Edited body before sending.\n".to_string(), + )); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); - fs::write( - &cache_path, - "---\ntitle: Design review\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", - ) - .expect("cache file"); - - let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); - let events_folder_id = RemoteId::new("google-calendar-folder:events"); - let long_event_id = format!("loc{}", "a".repeat(1024)); - let event_id_hash = locality_core::shadow::stable_hash(&long_event_id); - let created_remote_id = RemoteId::new(format!("google-calendar-event:primary:{long_event_id}")); - let mut store = InMemoryStateStore::new(); - store - .save_mount( - MountConfig::new(fixture.mount_id.clone(), "google-calendar", &fixture.root) - .projection(ProjectionMode::LinuxFuse), - ) - .expect("save mount"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - draft_folder_id.clone(), - EntityKind::Directory, - "draft", - "draft", - )) - .expect("save draft folder"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - events_folder_id.clone(), - EntityKind::Directory, - "events", - "events", - )) - .expect("save events folder"); + fs::write(&cache_path, &edited).expect("edit moved draft"); store - .save_virtual_mutation(virtual_mutation( - &fixture.mount_id, - "local:calendar-draft", - VirtualMutationKind::Create, - None, - Some(draft_folder_id), - "draft/design-review.md", - Some(cache_path), - )) - .expect("save mutation"); - let source = FakePushSource::default() - .with_created_entity( - created_remote_id.clone(), - rendered_google_calendar_entity( - created_remote_id.as_str(), - "Design review", - "2026-07-20T10:00:00-07:00", - "Agenda", - ), - ) - .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-calendar-draft".to_string()), - operation_index: 0, - parent_id: events_folder_id, - entity_id: created_remote_id.clone(), - }]); + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(original_path.to_path_buf()), + projected_path: source_path.to_path_buf(), + title: "Send Remote Draft".to_string(), + content_path: Some(cache_path), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); let report = execute_push_job_with_content_root( &mut store, PushJob { target_path: fixture.root.join(source_path), assume_yes: true, - confirm_dangerous: false, + confirm_dangerous: true, }, - &source, + &connector, Some(&state_root), ) - .expect("push google calendar draft with long event id"); + .expect("push draft send"); assert_eq!(report.action, PushJobAction::Reconciled); - let event = store - .get_entity(&fixture.mount_id, &created_remote_id) - .expect("get created event") - .expect("created event entity"); - let filename = event - .path - .file_name() - .expect("event filename") - .to_string_lossy(); + let calls = api.calls(); + assert_eq!(calls.updated_drafts.len(), 1); + assert_eq!(calls.updated_drafts[0].0, "draft-1"); + assert_eq!(calls.sent_drafts, vec!["draft-1"]); assert!( - filename.len() <= 255, - "filename component must fit common filesystem limits: {}", - filename.len() + calls + .call_log + .iter() + .position(|call| call == "update_draft:draft-1") + < calls + .call_log + .iter() + .position(|call| call == "send_draft:draft-1") ); - assert!(filename.starts_with("20260720-100000-design-review-")); - assert!(filename.ends_with(".md")); + let sent = store + .get_entity(&fixture.mount_id, &sent_remote_id) + .expect("get sent message") + .expect("sent message entity"); + assert_eq!(sent.remote_id, sent_remote_id); + assert!(sent.path.starts_with("sent")); + assert!(content_root.join(&sent.path).exists()); + assert!(!content_root.join(original_path).exists()); + assert!(!content_root.join(source_path).exists()); assert!( - filename.contains(&event_id_hash[..16]), - "shortened event ids should keep a stable hash suffix" + store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("get archived draft") + .is_none() + ); + assert!( + store + .find_virtual_mutation_by_path(&fixture.mount_id, source_path) + .expect("find move mutation") + .is_none() ); - assert!(content_root.join(&event.path).exists()); - assert!(!content_root.join(source_path).exists()); } #[test] -fn daemon_push_accepts_google_calendar_summary_only_draft_create() { +fn daemon_push_resumes_gmail_draft_send_reconciliation_without_reapplying() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); - let source_path = Path::new("draft/summary-only.md"); + let original_path = Path::new("draft/Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let sent_remote_id = RemoteId::new("gmail-message:sent-1"); + let api = Arc::new(RecordingGmailApi::new().with_sent_fetch_failures(&sent_remote_id, 1)); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let mut store = gmail_draft_store(&fixture, &connector, &draft_remote_id, original_path); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Send Remote Draft", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at("gmail:draft-message-1:1720900000000:DRAFT"), + ) + .expect("save moved draft entity"); + let rendered = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load draft shadow"); + let edited_frontmatter = rendered + .frontmatter + .replace("title: \"Remote Draft\"", "title: \"Send Remote Draft\"") + .replace("to: [\"ann@example.com\"]", "to: [\"bob@example.com\"]") + .replace( + "subject: \"Remote Draft\"", + "subject: \"Send Remote Draft\"", + ); + let edited = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter.clone(), + "Edited body before sending.\n".to_string(), + )); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); - fs::write( - &cache_path, - "---\nsummary: Summary only review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", - ) - .expect("cache file"); - - let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); - let events_folder_id = RemoteId::new("google-calendar-folder:events"); - let created_remote_id = RemoteId::new("google-calendar-event:primary:summary-only-event"); - let mut store = InMemoryStateStore::new(); - store - .save_mount( - MountConfig::new(fixture.mount_id.clone(), "google-calendar", &fixture.root) - .projection(ProjectionMode::LinuxFuse), - ) - .expect("save mount"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - draft_folder_id.clone(), - EntityKind::Directory, - "draft", - "draft", - )) - .expect("save draft folder"); + fs::write(&cache_path, &edited).expect("edit moved draft"); store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - events_folder_id.clone(), - EntityKind::Directory, - "events", - "events", - )) - .expect("save events folder"); + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(original_path.to_path_buf()), + projected_path: source_path.to_path_buf(), + title: "Send Remote Draft".to_string(), + content_path: Some(cache_path.clone()), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + let job = || PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: true, + }; + + let first = + execute_push_job_with_content_root(&mut store, job(), &connector, Some(&state_root)) + .expect("first draft send push"); + + assert_eq!(first.action, PushJobAction::Failed); + let first_push_id = first.push_id.expect("first push id"); + let calls = api.calls(); + assert_eq!(calls.updated_drafts.len(), 1); + assert_eq!(calls.sent_drafts, vec!["draft-1"]); + let stale_edit = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + "Changed after send readback failed.\n".to_string(), + )); + fs::write(&cache_path, &stale_edit).expect("write stale local edit"); + + let second = + execute_push_job_with_content_root(&mut store, job(), &connector, Some(&state_root)) + .expect("resume draft send push"); + + assert_eq!(second.action, PushJobAction::Reconciled); + assert_eq!(second.push_id.as_ref(), Some(&first_push_id)); + let calls = api.calls(); + assert_eq!( + calls.updated_drafts.len(), + 1, + "retry must not update the sent draft again" + ); + assert_eq!( + calls.sent_drafts, + vec!["draft-1"], + "retry must not resend Gmail draft" + ); + let sent = store + .get_entity(&fixture.mount_id, &sent_remote_id) + .expect("get sent message") + .expect("sent message entity"); + assert!(sent.path.starts_with("sent")); + assert!(content_root.join(&sent.path).exists()); + assert!( + store + .get_entity(&fixture.mount_id, &draft_remote_id) + .expect("get archived draft") + .is_none() + ); + assert_eq!( + fs::read_to_string(content_root.join(source_path)).expect("preserved stale edit"), + stale_edit + ); + let mutation = store + .find_virtual_mutation_by_path(&fixture.mount_id, source_path) + .expect("find preserved mutation") + .expect("preserved mutation"); + assert_eq!(mutation.mutation_kind, VirtualMutationKind::Create); + assert_eq!(mutation.target_remote_id, None); + assert_eq!( + mutation.parent_remote_id, + Some(RemoteId::new("gmail-folder:outbox")) + ); +} + +#[test] +fn daemon_push_resumes_gmail_draft_send_after_outbox_rename_without_reapplying() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let original_path = Path::new("draft/Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + let renamed_path = Path::new("outbox/Renamed Remote Draft.md"); + let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let sent_remote_id = RemoteId::new("gmail-message:sent-1"); + let api = Arc::new(RecordingGmailApi::new().with_sent_fetch_failures(&sent_remote_id, 1)); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let mut store = gmail_draft_store(&fixture, &connector, &draft_remote_id, original_path); store - .save_virtual_mutation(virtual_mutation( - &fixture.mount_id, - "local:calendar-summary-only-draft", - VirtualMutationKind::Create, - None, - Some(draft_folder_id), - "draft/summary-only.md", - Some(cache_path), - )) - .expect("save mutation"); - let source = FakePushSource::default() - .with_created_entity( - created_remote_id.clone(), - rendered_google_calendar_entity( - "google-calendar-event:primary:summary-only-event", - "Summary only review", - "2026-07-20T10:00:00-07:00", - "Agenda", - ), + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Send Remote Draft", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at("gmail:draft-message-1:1720900000000:DRAFT"), ) - .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-calendar-summary-only-draft".to_string()), - operation_index: 0, - parent_id: events_folder_id, - entity_id: created_remote_id, - }]); + .expect("save moved draft entity"); + let rendered = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load draft shadow"); + let edited_frontmatter = rendered + .frontmatter + .replace("title: \"Remote Draft\"", "title: \"Send Remote Draft\"") + .replace("to: [\"ann@example.com\"]", "to: [\"bob@example.com\"]") + .replace( + "subject: \"Remote Draft\"", + "subject: \"Send Remote Draft\"", + ); + let edited = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + "Edited body before sending.\n".to_string(), + )); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write(&cache_path, &edited).expect("edit moved draft"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(original_path.to_path_buf()), + projected_path: source_path.to_path_buf(), + title: "Send Remote Draft".to_string(), + content_path: Some(cache_path.clone()), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); - let report = execute_push_job_with_content_root( + let first = execute_push_job_with_content_root( &mut store, PushJob { target_path: fixture.root.join(source_path), assume_yes: true, - confirm_dangerous: false, + confirm_dangerous: true, }, - &source, + &connector, Some(&state_root), ) - .expect("push summary-only google calendar draft"); + .expect("first draft send push"); - assert_eq!(report.action, PushJobAction::Reconciled); - let journal = store.list_journal().expect("journal"); - assert_eq!(journal.len(), 1); - let PushOperation::CreateEntity { title, .. } = &journal[0].plan.operations[0] else { - panic!("expected create entity operation"); + assert_eq!(first.action, PushJobAction::Failed); + let first_push_id = first.push_id.expect("first push id"); + let calls = api.calls(); + assert_eq!(calls.updated_drafts.len(), 1); + assert_eq!(calls.sent_drafts, vec!["draft-1"]); + + let renamed_cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, renamed_path) + .expect("renamed cache path"); + fs::create_dir_all(renamed_cache_path.parent().expect("renamed parent")) + .expect("renamed cache parent"); + let renamed_edit = render_canonical_markdown(&CanonicalDocument::new( + "loc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: \"Renamed Remote Draft\"\nsubject: \"Renamed Remote Draft\"\nto: [\"bob@example.com\"]\n".to_string(), + "Changed after send readback failed.\n".to_string(), + )); + fs::remove_file(&cache_path).expect("remove old cache path"); + fs::write(&renamed_cache_path, &renamed_edit).expect("write renamed stale edit"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(original_path.to_path_buf()), + projected_path: renamed_path.to_path_buf(), + title: "Renamed Remote Draft".to_string(), + content_path: Some(renamed_cache_path.clone()), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:01:00Z".to_string(), + }) + .expect("save renamed move mutation"); + + let second = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(renamed_path), + assume_yes: true, + confirm_dangerous: true, + }, + &connector, + Some(&state_root), + ) + .expect("resume renamed draft send push"); + + assert_eq!(second.action, PushJobAction::Reconciled); + assert_eq!(second.push_id.as_ref(), Some(&first_push_id)); + let calls = api.calls(); + assert_eq!( + calls.updated_drafts.len(), + 1, + "retry must not update the sent draft again" + ); + assert_eq!( + calls.sent_drafts, + vec!["draft-1"], + "retry must not resend Gmail draft" + ); + let sent = store + .get_entity(&fixture.mount_id, &sent_remote_id) + .expect("get sent message") + .expect("sent message entity"); + assert!(sent.path.starts_with("sent")); + assert!(content_root.join(&sent.path).exists()); + assert_eq!( + fs::read_to_string(content_root.join(renamed_path)).expect("preserved renamed edit"), + renamed_edit + ); + let mutation = store + .find_virtual_mutation_by_path(&fixture.mount_id, renamed_path) + .expect("find preserved renamed mutation") + .expect("preserved renamed mutation"); + assert_eq!(mutation.mutation_kind, VirtualMutationKind::Create); + assert_eq!(mutation.target_remote_id, None); + assert_eq!(mutation.content_path, Some(renamed_cache_path)); +} + +#[test] +fn daemon_push_converts_preserved_gmail_outbox_edit_with_sqlite_store() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let original_path = Path::new("draft/Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let sent_remote_id = RemoteId::new("gmail-message:sent-1"); + let api = Arc::new(RecordingGmailApi::new().with_sent_fetch_failures(&sent_remote_id, 1)); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let mut store = SqliteStateStore::open(state_root.clone()).expect("open sqlite store"); + seed_gmail_draft_store( + &mut store, + &fixture, + &connector, + &draft_remote_id, + original_path, + ); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Send Remote Draft", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at("gmail:draft-message-1:1720900000000:DRAFT"), + ) + .expect("save moved draft entity"); + let rendered = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load draft shadow"); + let edited_frontmatter = rendered + .frontmatter + .replace("title: \"Remote Draft\"", "title: \"Send Remote Draft\"") + .replace("to: [\"ann@example.com\"]", "to: [\"bob@example.com\"]") + .replace( + "subject: \"Remote Draft\"", + "subject: \"Send Remote Draft\"", + ); + let edited = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter.clone(), + "Edited body before sending.\n".to_string(), + )); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write(&cache_path, &edited).expect("edit moved draft"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(original_path.to_path_buf()), + projected_path: source_path.to_path_buf(), + title: "Send Remote Draft".to_string(), + content_path: Some(cache_path.clone()), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + let job = || PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: true, }; - assert_eq!(title, "Summary only review"); + + let first = + execute_push_job_with_content_root(&mut store, job(), &connector, Some(&state_root)) + .expect("first draft send push"); + + assert_eq!(first.action, PushJobAction::Failed); + let stale_edit = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + "Changed after send readback failed.\n".to_string(), + )); + fs::write(&cache_path, &stale_edit).expect("write stale local edit"); + + let second = + execute_push_job_with_content_root(&mut store, job(), &connector, Some(&state_root)) + .expect("resume draft send push"); + + assert_eq!(second.action, PushJobAction::Reconciled); + let calls = api.calls(); + assert_eq!( + calls.updated_drafts.len(), + 1, + "retry must not update the sent draft again" + ); + assert_eq!( + calls.sent_drafts, + vec!["draft-1"], + "retry must not resend Gmail draft" + ); + assert_eq!( + fs::read_to_string(content_root.join(source_path)).expect("preserved stale edit"), + stale_edit + ); + let mutation = store + .find_virtual_mutation_by_path(&fixture.mount_id, source_path) + .expect("find preserved mutation") + .expect("preserved mutation"); + assert_eq!(mutation.mutation_kind, VirtualMutationKind::Create); + assert_eq!(mutation.target_remote_id, None); + assert_eq!(mutation.content_path, Some(cache_path)); } #[test] -fn daemon_push_preserves_edited_google_calendar_draft_after_create_reconcile_retry() { +fn daemon_push_resumes_applying_gmail_draft_send_with_complete_effects() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let original_path = Path::new("draft/Remote Draft.md"); + let source_path = Path::new("outbox/Send Remote Draft.md"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let sent_remote_id = RemoteId::new("gmail-message:sent-1"); + let api = Arc::new(RecordingGmailApi::new().with_sent_fetch_failures(&sent_remote_id, 1)); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let mut store = gmail_draft_store(&fixture, &connector, &draft_remote_id, original_path); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Send Remote Draft", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at("gmail:draft-message-1:1720900000000:DRAFT"), + ) + .expect("save moved draft entity"); + let rendered = store + .load_shadow(&fixture.mount_id, &draft_remote_id) + .expect("load draft shadow"); + let edited_frontmatter = rendered + .frontmatter + .replace("title: \"Remote Draft\"", "title: \"Send Remote Draft\"") + .replace("to: [\"ann@example.com\"]", "to: [\"bob@example.com\"]") + .replace( + "subject: \"Remote Draft\"", + "subject: \"Send Remote Draft\"", + ); + let edited = render_canonical_markdown(&CanonicalDocument::new( + edited_frontmatter, + "Edited body before sending.\n".to_string(), + )); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write(&cache_path, &edited).expect("edit moved draft"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(original_path.to_path_buf()), + projected_path: source_path.to_path_buf(), + title: "Send Remote Draft".to_string(), + content_path: Some(cache_path), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + let job = || PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: true, + }; + + let first = + execute_push_job_with_content_root(&mut store, job(), &connector, Some(&state_root)) + .expect("first draft send push"); + + assert_eq!(first.action, PushJobAction::Failed); + let first_push_id = first.push_id.expect("first push id"); + store + .update_journal_status(&first_push_id, JournalStatus::Applying) + .expect("force applying status"); + + let second = + execute_push_job_with_content_root(&mut store, job(), &connector, Some(&state_root)) + .expect("resume applying draft send push"); + + assert_eq!(second.action, PushJobAction::Reconciled); + assert_eq!(second.push_id.as_ref(), Some(&first_push_id)); + let calls = api.calls(); + assert_eq!( + calls.updated_drafts.len(), + 1, + "retry must not update the sent draft again" + ); + assert_eq!( + calls.sent_drafts, + vec!["draft-1"], + "retry must not resend Gmail draft" + ); +} + +#[test] +fn daemon_push_reconciles_google_calendar_draft_create_to_canonical_event_filename() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/design-review.md"); @@ -1560,10 +1921,9 @@ fn daemon_push_preserves_edited_google_calendar_draft_after_create_reconcile_ret let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); - let edited_draft = "---\nsummary: Follow-up design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nUpdated agenda\n"; fs::write( &cache_path, - "---\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", + "---\ntitle: Design review\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", ) .expect("cache file"); @@ -1604,7 +1964,7 @@ fn daemon_push_preserves_edited_google_calendar_draft_after_create_reconcile_ret None, Some(draft_folder_id), "draft/design-review.md", - Some(cache_path.clone()), + Some(cache_path), )) .expect("save mutation"); let source = FakePushSource::default() @@ -1617,71 +1977,70 @@ fn daemon_push_preserves_edited_google_calendar_draft_after_create_reconcile_ret "Agenda", ), ) - .with_created_fetch_failures(created_remote_id.clone(), 1) .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { operation_id: PushOperationId("create-calendar-draft".to_string()), operation_index: 0, parent_id: events_folder_id, entity_id: created_remote_id.clone(), }]); - let job = || PushJob { - target_path: fixture.root.join(source_path), - assume_yes: true, - confirm_dangerous: false, - }; - - let first = execute_push_job_with_content_root(&mut store, job(), &source, Some(&state_root)) - .expect("first push"); - - assert_eq!(first.action, PushJobAction::Failed); - assert_eq!(source.applied_count(), 1); - let first_push_id = first.push_id.expect("first push id"); - fs::write(&cache_path, edited_draft).expect("edit stale draft"); - let second = execute_push_job_with_content_root(&mut store, job(), &source, Some(&state_root)) - .expect("retry push"); + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("push google calendar draft"); - assert_eq!(second.action, PushJobAction::Reconciled); - assert_eq!( - source.applied_count(), - 1, - "retry must not recreate Calendar event" - ); - assert_eq!(second.push_id.as_ref(), Some(&first_push_id)); + assert_eq!(report.action, PushJobAction::Reconciled); let event = store .get_entity(&fixture.mount_id, &created_remote_id) .expect("get created event") .expect("created event entity"); assert_eq!(event.path, expected_path); - assert!(content_root.join(&expected_path).exists()); + let requested_paths = source.requested_paths(); assert_eq!( - fs::read_to_string(content_root.join(source_path)).expect("preserved edited draft"), - edited_draft + requested_paths, + vec![ + PathBuf::from("events/design-review.md"), + expected_path.clone() + ] ); + assert_eq!(requested_paths.last(), Some(&expected_path)); + assert!(content_root.join(&expected_path).exists()); + assert!(!content_root.join(source_path).exists()); assert!( store .find_virtual_mutation_by_path(&fixture.mount_id, source_path) .expect("find mutation") - .is_some() + .is_none() ); } #[test] -fn auto_save_push_blocks_google_calendar_draft_create_without_applying() { +fn daemon_push_reconciles_long_google_calendar_event_id_to_filesystem_safe_filename() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/design-review.md"); + let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); fs::write( &cache_path, - "---\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", + "---\ntitle: Design review\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", ) .expect("cache file"); let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); - let created_remote_id = RemoteId::new("google-calendar-event:primary:created-event"); + let events_folder_id = RemoteId::new("google-calendar-folder:events"); + let long_event_id = format!("loc{}", "a".repeat(1024)); + let event_id_hash = locality_core::shadow::stable_hash(&long_event_id); + let created_remote_id = RemoteId::new(format!("google-calendar-event:primary:{long_event_id}")); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -1698,6 +2057,15 @@ fn auto_save_push_blocks_google_calendar_draft_create_without_applying() { "draft", )) .expect("save draft folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + events_folder_id.clone(), + EntityKind::Directory, + "events", + "events", + )) + .expect("save events folder"); store .save_virtual_mutation(virtual_mutation( &fixture.mount_id, @@ -1709,218 +2077,222 @@ fn auto_save_push_blocks_google_calendar_draft_create_without_applying() { Some(cache_path), )) .expect("save mutation"); - store - .save_auto_save_enrollment(AutoSaveEnrollmentRecord::new( - fixture.mount_id.clone(), - source_path, - AutoSaveOrigin::LocalityCreated, - "now", - )) - .expect("save enrollment"); - let source = - FakePushSource::default().with_apply_effects(vec![JournalApplyEffect::CreatedEntity { + let source = FakePushSource::default() + .with_created_entity( + created_remote_id.clone(), + rendered_google_calendar_entity( + created_remote_id.as_str(), + "Design review", + "2026-07-20T10:00:00-07:00", + "Agenda", + ), + ) + .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { operation_id: PushOperationId("create-calendar-draft".to_string()), operation_index: 0, - parent_id: RemoteId::new("google-calendar-folder:events"), - entity_id: created_remote_id, + parent_id: events_folder_id, + entity_id: created_remote_id.clone(), }]); - let report = execute_auto_save_push_job_with_content_root( + let report = execute_push_job_with_content_root( &mut store, PushJob { target_path: fixture.root.join(source_path), - assume_yes: false, + assume_yes: true, confirm_dangerous: false, }, &source, Some(&state_root), ) - .expect("auto-save google calendar draft"); + .expect("push google calendar draft with long event id"); - assert_eq!(report.action, PushJobAction::NotReady); - assert_eq!( - source.applied_count(), - 0, - "auto-save must not create Calendar events" - ); - assert_eq!( - report.error.as_ref().expect("error").code, - "auto_save_blocked" - ); - assert_eq!( - report.error.as_ref().expect("error").message, - "Google Calendar event creates require review" + assert_eq!(report.action, PushJobAction::Reconciled); + let event = store + .get_entity(&fixture.mount_id, &created_remote_id) + .expect("get created event") + .expect("created event entity"); + let filename = event + .path + .file_name() + .expect("event filename") + .to_string_lossy(); + assert!( + filename.len() <= 255, + "filename component must fit common filesystem limits: {}", + filename.len() ); - let enrollment = store - .get_auto_save_enrollment(&fixture.mount_id, source_path) - .expect("get enrollment") - .expect("enrollment"); - assert_eq!(enrollment.state, AutoSaveState::Blocked); - assert_eq!( - enrollment.last_reason.as_deref(), - Some("Google Calendar event creates require review") + assert!(filename.starts_with("20260720-100000-design-review-")); + assert!(filename.ends_with(".md")); + assert!( + filename.contains(&event_id_hash[..16]), + "shortened event ids should keep a stable hash suffix" ); - assert!(store.list_journal().expect("journal").is_empty()); + assert!(content_root.join(&event.path).exists()); + assert!(!content_root.join(source_path).exists()); } #[test] -fn auto_save_push_blocks_gmail_direct_send_without_applying() { +fn daemon_push_accepts_google_calendar_summary_only_draft_create() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); - let source_path = Path::new("outbox/reply.md"); + let source_path = Path::new("draft/summary-only.md"); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); fs::write( &cache_path, - "---\ntitle: Reply\nto: [\"user@example.com\"]\nsubject: Reply\n---\nBody.\n", + "---\nsummary: Summary only review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", ) .expect("cache file"); - let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); + let events_folder_id = RemoteId::new("google-calendar-folder:events"); + let created_remote_id = RemoteId::new("google-calendar-event:primary:summary-only-event"); let mut store = InMemoryStateStore::new(); store .save_mount( - MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + MountConfig::new(fixture.mount_id.clone(), "google-calendar", &fixture.root) .projection(ProjectionMode::LinuxFuse), ) .expect("save mount"); store .save_entity(EntityRecord::new( fixture.mount_id.clone(), - outbox_folder_id.clone(), + draft_folder_id.clone(), EntityKind::Directory, - "outbox", - "outbox", + "draft", + "draft", )) - .expect("save outbox folder"); + .expect("save draft folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + events_folder_id.clone(), + EntityKind::Directory, + "events", + "events", + )) + .expect("save events folder"); store .save_virtual_mutation(virtual_mutation( &fixture.mount_id, - "local:gmail-outbox", + "local:calendar-summary-only-draft", VirtualMutationKind::Create, None, - Some(outbox_folder_id), - "outbox/reply.md", + Some(draft_folder_id), + "draft/summary-only.md", Some(cache_path), )) .expect("save mutation"); - store - .save_auto_save_enrollment(AutoSaveEnrollmentRecord::new( - fixture.mount_id.clone(), - source_path, - AutoSaveOrigin::LocalityCreated, - "now", - )) - .expect("save enrollment"); - let source = - FakePushSource::default().with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-gmail-outbox".to_string()), + let source = FakePushSource::default() + .with_created_entity( + created_remote_id.clone(), + rendered_google_calendar_entity( + "google-calendar-event:primary:summary-only-event", + "Summary only review", + "2026-07-20T10:00:00-07:00", + "Agenda", + ), + ) + .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("create-calendar-summary-only-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id, + parent_id: events_folder_id, entity_id: created_remote_id, }]); - let report = execute_auto_save_push_job_with_content_root( + let report = execute_push_job_with_content_root( &mut store, PushJob { target_path: fixture.root.join(source_path), - assume_yes: false, + assume_yes: true, confirm_dangerous: false, }, &source, Some(&state_root), ) - .expect("auto-save gmail send"); + .expect("push summary-only google calendar draft"); - assert_eq!(report.action, PushJobAction::NotReady); - assert_eq!(source.applied_count(), 0, "auto-save must not send Gmail"); - assert_eq!( - report.error.as_ref().expect("error").code, - "auto_save_blocked" - ); - assert_eq!( - report.error.as_ref().expect("error").message, - "Gmail outbound email creates require review" - ); - let enrollment = store - .get_auto_save_enrollment(&fixture.mount_id, source_path) - .expect("get enrollment") - .expect("enrollment"); - assert_eq!(enrollment.state, AutoSaveState::Blocked); - assert_eq!( - enrollment.last_reason.as_deref(), - Some("Gmail outbound email creates require review") - ); - assert!(store.list_journal().expect("journal").is_empty()); + assert_eq!(report.action, PushJobAction::Reconciled); + let journal = store.list_journal().expect("journal"); + assert_eq!(journal.len(), 1); + let PushOperation::CreateEntity { title, .. } = &journal[0].plan.operations[0] else { + panic!("expected create entity operation"); + }; + assert_eq!(title, "Summary only review"); } #[test] -fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { +fn daemon_push_preserves_edited_google_calendar_draft_after_create_reconcile_retry() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); - let source_path = Path::new("outbox/reply.md"); + let source_path = Path::new("draft/design-review.md"); let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + let edited_draft = "---\nsummary: Follow-up design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nUpdated agenda\n"; fs::write( &cache_path, - "---\ntitle: Reply\nto: [\"user@example.com\"]\nsubject: Reply\n---\nBody.\n", + "---\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", ) .expect("cache file"); - let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); + let events_folder_id = RemoteId::new("google-calendar-folder:events"); + let created_remote_id = RemoteId::new("google-calendar-event:primary:created-event"); + let expected_path = PathBuf::from("events/20260720-100000-design-review-created-event.md"); let mut store = InMemoryStateStore::new(); store .save_mount( - MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + MountConfig::new(fixture.mount_id.clone(), "google-calendar", &fixture.root) .projection(ProjectionMode::LinuxFuse), ) .expect("save mount"); store .save_entity(EntityRecord::new( fixture.mount_id.clone(), - outbox_folder_id.clone(), + draft_folder_id.clone(), EntityKind::Directory, - "outbox", - "outbox", + "draft", + "draft", )) - .expect("save outbox folder"); + .expect("save draft folder"); store .save_entity(EntityRecord::new( fixture.mount_id.clone(), - sent_folder_id.clone(), + events_folder_id.clone(), EntityKind::Directory, - "sent", - "sent", + "events", + "events", )) - .expect("save sent folder"); + .expect("save events folder"); store .save_virtual_mutation(virtual_mutation( &fixture.mount_id, - "local:gmail-outbox", + "local:calendar-draft", VirtualMutationKind::Create, None, - Some(outbox_folder_id), - "outbox/reply.md", - Some(cache_path), + Some(draft_folder_id), + "draft/design-review.md", + Some(cache_path.clone()), )) .expect("save mutation"); let source = FakePushSource::default() .with_created_entity( created_remote_id.clone(), - rendered_entity("gmail-message:sent-1", "Body."), + rendered_google_calendar_entity( + "google-calendar-event:primary:created-event", + "Design review", + "2026-07-20T10:00:00-07:00", + "Agenda", + ), ) .with_created_fetch_failures(created_remote_id.clone(), 1) .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-gmail-outbox".to_string()), + operation_id: PushOperationId("create-calendar-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id, + parent_id: events_folder_id, entity_id: created_remote_id.clone(), }]); let job = || PushJob { @@ -1935,37 +2307,27 @@ fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { assert_eq!(first.action, PushJobAction::Failed); assert_eq!(source.applied_count(), 1); let first_push_id = first.push_id.expect("first push id"); - let journal = store.list_journal().expect("journal"); - assert_eq!(journal.len(), 1); - assert!(matches!(journal[0].status, JournalStatus::Failed(_))); - assert_eq!(journal[0].apply_effects.len(), 1); - let edited_cache_path = - virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); - fs::write( - &edited_cache_path, - "---\ntitle: Edited reply\nto: [\"user@example.com\"]\nsubject: Edited reply\n---\nChanged body.\n", - ) - .expect("edit stale send"); + fs::write(&cache_path, edited_draft).expect("edit stale draft"); let second = execute_push_job_with_content_root(&mut store, job(), &source, Some(&state_root)) .expect("retry push"); assert_eq!(second.action, PushJobAction::Reconciled); - assert_eq!(source.applied_count(), 1, "retry must not resend Gmail"); + assert_eq!( + source.applied_count(), + 1, + "retry must not recreate Calendar event" + ); assert_eq!(second.push_id.as_ref(), Some(&first_push_id)); - let journal = store.list_journal().expect("journal"); - assert_eq!(journal.len(), 1); - assert_eq!(journal[0].status, JournalStatus::Reconciled); - let message = store + let event = store .get_entity(&fixture.mount_id, &created_remote_id) - .expect("get sent message") - .expect("sent message entity"); - assert_eq!(message.path, PathBuf::from("sent/reply.md")); - assert!(content_root.join("sent/reply.md").exists()); - assert!(content_root.join(source_path).exists()); + .expect("get created event") + .expect("created event entity"); + assert_eq!(event.path, expected_path); + assert!(content_root.join(&expected_path).exists()); assert_eq!( - fs::read_to_string(content_root.join(source_path)).expect("preserved edited send"), - "---\ntitle: Edited reply\nto: [\"user@example.com\"]\nsubject: Edited reply\n---\nChanged body.\n" + fs::read_to_string(content_root.join(source_path)).expect("preserved edited draft"), + edited_draft ); assert!( store @@ -1976,11 +2338,107 @@ fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { } #[test] -fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { +fn auto_save_push_blocks_google_calendar_draft_create_without_applying() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let source_path = Path::new("draft/design-review.md"); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &cache_path, + "---\nsummary: Design review\nstart:\n dateTime: \"2026-07-20T10:00:00-07:00\"\nend:\n dateTime: \"2026-07-20T10:30:00-07:00\"\n---\nAgenda\n", + ) + .expect("cache file"); + + let draft_folder_id = RemoteId::new("google-calendar-folder:draft"); + let created_remote_id = RemoteId::new("google-calendar-event:primary:created-event"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "google-calendar", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_virtual_mutation(virtual_mutation( + &fixture.mount_id, + "local:calendar-draft", + VirtualMutationKind::Create, + None, + Some(draft_folder_id), + "draft/design-review.md", + Some(cache_path), + )) + .expect("save mutation"); + store + .save_auto_save_enrollment(AutoSaveEnrollmentRecord::new( + fixture.mount_id.clone(), + source_path, + AutoSaveOrigin::LocalityCreated, + "now", + )) + .expect("save enrollment"); + let source = + FakePushSource::default().with_apply_effects(vec![JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("create-calendar-draft".to_string()), + operation_index: 0, + parent_id: RemoteId::new("google-calendar-folder:events"), + entity_id: created_remote_id, + }]); + + let report = execute_auto_save_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: false, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("auto-save google calendar draft"); + + assert_eq!(report.action, PushJobAction::NotReady); + assert_eq!( + source.applied_count(), + 0, + "auto-save must not create Calendar events" + ); + assert_eq!( + report.error.as_ref().expect("error").code, + "auto_save_blocked" + ); + assert_eq!( + report.error.as_ref().expect("error").message, + "Google Calendar event creates require review" + ); + let enrollment = store + .get_auto_save_enrollment(&fixture.mount_id, source_path) + .expect("get enrollment") + .expect("enrollment"); + assert_eq!(enrollment.state, AutoSaveState::Blocked); + assert_eq!( + enrollment.last_reason.as_deref(), + Some("Google Calendar event creates require review") + ); + assert!(store.list_journal().expect("journal").is_empty()); +} + +#[test] +fn auto_save_push_blocks_gmail_direct_send_without_applying() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("outbox/reply.md"); - let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); @@ -2009,106 +2467,73 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { "outbox", )) .expect("save outbox folder"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - sent_folder_id.clone(), - EntityKind::Directory, - "sent", - "sent", - )) - .expect("save sent folder"); store .save_virtual_mutation(virtual_mutation( &fixture.mount_id, "local:gmail-outbox", VirtualMutationKind::Create, None, - Some(outbox_folder_id.clone()), + Some(outbox_folder_id), "outbox/reply.md", Some(cache_path), )) .expect("save mutation"); - - let mut properties = BTreeMap::new(); - properties.insert( - "subject".to_string(), - PropertyValue::String("Reply".to_string()), - ); - properties.insert( - "to".to_string(), - PropertyValue::List(vec!["user@example.com".to_string()]), - ); - let plan = PushPlan::new( - vec![outbox_folder_id], - vec![PushOperation::CreateEntity { - parent_id: RemoteId::new("gmail-folder:outbox"), - parent_kind: Some(EntityKind::Directory), - parent_workspace: false, - title: "Reply".to_string(), - properties, - body: "Body.\n".to_string(), - source_path: source_path.to_path_buf(), - }], - ); - let push_id = PushId("push-already-applied-gmail-outbox".to_string()); - let effect = JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-gmail-outbox".to_string()), - operation_index: 0, - parent_id: sent_folder_id.clone(), - entity_id: created_remote_id.clone(), - }; store - .append_journal( - JournalEntry::new( - push_id.clone(), - fixture.mount_id.clone(), - plan.affected_entities.clone(), - plan, - JournalStatus::Applied, - ) - .with_apply_effects(vec![effect.clone()]), - ) - .expect("append applied journal"); - let source = FakePushSource::default() - .with_created_entity( - created_remote_id.clone(), - rendered_entity("gmail-message:sent-1", "Body."), - ) - .with_apply_effects(vec![effect]); + .save_auto_save_enrollment(AutoSaveEnrollmentRecord::new( + fixture.mount_id.clone(), + source_path, + AutoSaveOrigin::LocalityCreated, + "now", + )) + .expect("save enrollment"); + let source = + FakePushSource::default().with_apply_effects(vec![JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("create-gmail-outbox".to_string()), + operation_index: 0, + parent_id: sent_folder_id, + entity_id: created_remote_id, + }]); - let report = execute_push_job_with_content_root( + let report = execute_auto_save_push_job_with_content_root( &mut store, PushJob { target_path: fixture.root.join(source_path), - assume_yes: true, + assume_yes: false, confirm_dangerous: false, }, &source, Some(&state_root), ) - .expect("retry applied gmail push"); + .expect("auto-save gmail send"); - assert_eq!(report.action, PushJobAction::Reconciled); - assert_eq!(source.applied_count(), 0, "retry must not resend Gmail"); - assert_eq!(report.push_id.as_ref(), Some(&push_id)); - let journal = store.list_journal().expect("journal"); - assert_eq!(journal.len(), 1); - assert_eq!(journal[0].status, JournalStatus::Reconciled); - let message = store - .get_entity(&fixture.mount_id, &created_remote_id) - .expect("get sent message") - .expect("sent message entity"); - assert_eq!(message.path, PathBuf::from("sent/reply.md")); - assert!(content_root.join("sent/reply.md").exists()); - assert!(!content_root.join(source_path).exists()); + assert_eq!(report.action, PushJobAction::NotReady); + assert_eq!(source.applied_count(), 0, "auto-save must not send Gmail"); + assert_eq!( + report.error.as_ref().expect("error").code, + "auto_save_blocked" + ); + assert_eq!( + report.error.as_ref().expect("error").message, + "Gmail outbound email creates require review" + ); + let enrollment = store + .get_auto_save_enrollment(&fixture.mount_id, source_path) + .expect("get enrollment") + .expect("enrollment"); + assert_eq!(enrollment.state, AutoSaveState::Blocked); + assert_eq!( + enrollment.last_reason.as_deref(), + Some("Gmail outbound email creates require review") + ); + assert!(store.list_journal().expect("journal").is_empty()); } #[test] -fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { +fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("outbox/reply.md"); + let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); let cache_path = virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); @@ -2120,6 +2545,7 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); let sent_folder_id = RemoteId::new("gmail-folder:sent"); + let created_remote_id = RemoteId::new("gmail-message:sent-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -2139,7 +2565,252 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { store .save_entity(EntityRecord::new( fixture.mount_id.clone(), - sent_folder_id, + sent_folder_id.clone(), + EntityKind::Directory, + "sent", + "sent", + )) + .expect("save sent folder"); + store + .save_virtual_mutation(virtual_mutation( + &fixture.mount_id, + "local:gmail-outbox", + VirtualMutationKind::Create, + None, + Some(outbox_folder_id), + "outbox/reply.md", + Some(cache_path), + )) + .expect("save mutation"); + let source = FakePushSource::default() + .with_created_entity( + created_remote_id.clone(), + rendered_entity("gmail-message:sent-1", "Body."), + ) + .with_created_fetch_failures(created_remote_id.clone(), 1) + .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("create-gmail-outbox".to_string()), + operation_index: 0, + parent_id: sent_folder_id, + entity_id: created_remote_id.clone(), + }]); + let job = || PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: false, + }; + + let first = execute_push_job_with_content_root(&mut store, job(), &source, Some(&state_root)) + .expect("first push"); + + assert_eq!(first.action, PushJobAction::Failed); + assert_eq!(source.applied_count(), 1); + let first_push_id = first.push_id.expect("first push id"); + let journal = store.list_journal().expect("journal"); + assert_eq!(journal.len(), 1); + assert!(matches!(journal[0].status, JournalStatus::Failed(_))); + assert_eq!(journal[0].apply_effects.len(), 1); + let edited_cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::write( + &edited_cache_path, + "---\ntitle: Edited reply\nto: [\"user@example.com\"]\nsubject: Edited reply\n---\nChanged body.\n", + ) + .expect("edit stale send"); + + let second = execute_push_job_with_content_root(&mut store, job(), &source, Some(&state_root)) + .expect("retry push"); + + assert_eq!(second.action, PushJobAction::Reconciled); + assert_eq!(source.applied_count(), 1, "retry must not resend Gmail"); + assert_eq!(second.push_id.as_ref(), Some(&first_push_id)); + let journal = store.list_journal().expect("journal"); + assert_eq!(journal.len(), 1); + assert_eq!(journal[0].status, JournalStatus::Reconciled); + let message = store + .get_entity(&fixture.mount_id, &created_remote_id) + .expect("get sent message") + .expect("sent message entity"); + assert_eq!(message.path, PathBuf::from("sent/reply.md")); + assert!(content_root.join("sent/reply.md").exists()); + assert!(content_root.join(source_path).exists()); + assert_eq!( + fs::read_to_string(content_root.join(source_path)).expect("preserved edited send"), + "---\ntitle: Edited reply\nto: [\"user@example.com\"]\nsubject: Edited reply\n---\nChanged body.\n" + ); + assert!( + store + .find_virtual_mutation_by_path(&fixture.mount_id, source_path) + .expect("find mutation") + .is_some() + ); +} + +#[test] +fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let source_path = Path::new("outbox/reply.md"); + let content_root = virtual_fs_content_root(&state_root, &fixture.mount_id); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &cache_path, + "---\ntitle: Reply\nto: [\"user@example.com\"]\nsubject: Reply\n---\nBody.\n", + ) + .expect("cache file"); + + let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); + let sent_folder_id = RemoteId::new("gmail-folder:sent"); + let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + outbox_folder_id.clone(), + EntityKind::Directory, + "outbox", + "outbox", + )) + .expect("save outbox folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + sent_folder_id.clone(), + EntityKind::Directory, + "sent", + "sent", + )) + .expect("save sent folder"); + store + .save_virtual_mutation(virtual_mutation( + &fixture.mount_id, + "local:gmail-outbox", + VirtualMutationKind::Create, + None, + Some(outbox_folder_id.clone()), + "outbox/reply.md", + Some(cache_path), + )) + .expect("save mutation"); + + let mut properties = BTreeMap::new(); + properties.insert( + "subject".to_string(), + PropertyValue::String("Reply".to_string()), + ); + properties.insert( + "to".to_string(), + PropertyValue::List(vec!["user@example.com".to_string()]), + ); + let plan = PushPlan::new( + vec![outbox_folder_id], + vec![PushOperation::CreateEntity { + parent_id: RemoteId::new("gmail-folder:outbox"), + parent_kind: Some(EntityKind::Directory), + parent_workspace: false, + title: "Reply".to_string(), + properties, + body: "Body.\n".to_string(), + source_path: source_path.to_path_buf(), + }], + ); + let push_id = PushId("push-already-applied-gmail-outbox".to_string()); + let effect = JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("create-gmail-outbox".to_string()), + operation_index: 0, + parent_id: sent_folder_id.clone(), + entity_id: created_remote_id.clone(), + }; + store + .append_journal( + JournalEntry::new( + push_id.clone(), + fixture.mount_id.clone(), + plan.affected_entities.clone(), + plan, + JournalStatus::Applied, + ) + .with_apply_effects(vec![effect.clone()]), + ) + .expect("append applied journal"); + let source = FakePushSource::default() + .with_created_entity( + created_remote_id.clone(), + rendered_entity("gmail-message:sent-1", "Body."), + ) + .with_apply_effects(vec![effect]); + + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("retry applied gmail push"); + + assert_eq!(report.action, PushJobAction::Reconciled); + assert_eq!(source.applied_count(), 0, "retry must not resend Gmail"); + assert_eq!(report.push_id.as_ref(), Some(&push_id)); + let journal = store.list_journal().expect("journal"); + assert_eq!(journal.len(), 1); + assert_eq!(journal[0].status, JournalStatus::Reconciled); + let message = store + .get_entity(&fixture.mount_id, &created_remote_id) + .expect("get sent message") + .expect("sent message entity"); + assert_eq!(message.path, PathBuf::from("sent/reply.md")); + assert!(content_root.join("sent/reply.md").exists()); + assert!(!content_root.join(source_path).exists()); +} + +#[test] +fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let source_path = Path::new("outbox/reply.md"); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &cache_path, + "---\ntitle: Reply\nto: [\"user@example.com\"]\nsubject: Reply\n---\nBody.\n", + ) + .expect("cache file"); + + let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); + let sent_folder_id = RemoteId::new("gmail-folder:sent"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + outbox_folder_id.clone(), + EntityKind::Directory, + "outbox", + "outbox", + )) + .expect("save outbox folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + sent_folder_id, EntityKind::Directory, "sent", "sent", @@ -2157,58 +2828,701 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { )) .expect("save mutation"); - let mut properties = BTreeMap::new(); - properties.insert( - "subject".to_string(), - PropertyValue::String("Reply".to_string()), - ); - properties.insert( - "to".to_string(), - PropertyValue::List(vec!["user@example.com".to_string()]), - ); - let plan = PushPlan::new( - vec![outbox_folder_id], - vec![PushOperation::CreateEntity { - parent_id: RemoteId::new("gmail-folder:outbox"), - parent_kind: Some(EntityKind::Directory), - parent_workspace: false, - title: "Reply".to_string(), - properties, - body: "Body.\n".to_string(), - source_path: source_path.to_path_buf(), + let mut properties = BTreeMap::new(); + properties.insert( + "subject".to_string(), + PropertyValue::String("Reply".to_string()), + ); + properties.insert( + "to".to_string(), + PropertyValue::List(vec!["user@example.com".to_string()]), + ); + let plan = PushPlan::new( + vec![outbox_folder_id], + vec![PushOperation::CreateEntity { + parent_id: RemoteId::new("gmail-folder:outbox"), + parent_kind: Some(EntityKind::Directory), + parent_workspace: false, + title: "Reply".to_string(), + properties, + body: "Body.\n".to_string(), + source_path: source_path.to_path_buf(), + }], + ); + let push_id = PushId("push-ambiguous-gmail-outbox".to_string()); + store + .append_journal(JournalEntry::new( + push_id.clone(), + fixture.mount_id.clone(), + plan.affected_entities.clone(), + plan, + JournalStatus::Applying, + )) + .expect("append applying journal"); + let source = FakePushSource::default(); + + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("retry ambiguous gmail push"); + + assert_eq!(report.action, PushJobAction::Failed); + assert_eq!(source.applied_count(), 0, "retry must not resend Gmail"); + assert_eq!(report.push_id.as_ref(), Some(&push_id)); + assert_eq!(report.journal_status, Some(JournalStatus::Applying)); + let error = report.error.expect("guardrail error"); + assert_eq!(error.code, "guardrail"); + assert!(error.message.contains("ambiguous result")); +} + +#[test] +fn daemon_push_blocks_ambiguous_gmail_draft_move_send_journal_without_reapplying() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let source_path = Path::new("outbox/Send Now.md"); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &cache_path, + "---\nloc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Send Now\nsubject: Send now subject\nto: [\"ann@example.com\"]\n---\nEdited body before sending.\n", + ) + .expect("cache file"); + + let draft_folder_id = RemoteId::new("gmail-folder:draft"); + let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + outbox_folder_id.clone(), + EntityKind::Directory, + "outbox", + "outbox", + )) + .expect("save outbox folder"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Original", + source_path, + ) + .with_hydration(HydrationState::Dirty), + ) + .expect("save moved draft"); + store + .save_shadow( + &fixture.mount_id, + ShadowDocument::from_synced_body( + draft_remote_id.clone(), + "Original body.\n", + 1, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter("loc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Original\nsubject: Original subject\nto: [\"ann@example.com\"]\n"), + ) + .expect("save shadow"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(outbox_folder_id.clone()), + original_path: Some(PathBuf::from("draft/Original.md")), + projected_path: source_path.to_path_buf(), + title: "Send Now".to_string(), + content_path: Some(cache_path), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + + let plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![ + PushOperation::MoveEntity { + entity_id: draft_remote_id.clone(), + new_parent_id: outbox_folder_id, + new_parent_kind: EntityKind::Directory, + new_title: "Send Now".to_string(), + projected_path: source_path.to_path_buf(), + }, + PushOperation::UpdateProperties { + entity_id: draft_remote_id.clone(), + properties: BTreeMap::from([( + "subject".to_string(), + PropertyValue::String("Send now subject".to_string()), + )]), + }, + PushOperation::UpdateEntityBody { + entity_id: draft_remote_id, + body: "Edited body before sending.\n".to_string(), + }, + ], + ); + let push_id = PushId("push-ambiguous-gmail-draft-send".to_string()); + store + .append_journal(JournalEntry::new( + push_id.clone(), + fixture.mount_id.clone(), + plan.affected_entities.clone(), + plan, + JournalStatus::Applying, + )) + .expect("append applying journal"); + let source = FakePushSource::default(); + + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("retry ambiguous gmail draft send"); + + assert_eq!(report.action, PushJobAction::Failed); + assert_eq!( + source.applied_count(), + 0, + "retry must not resend Gmail draft" + ); + assert_eq!(report.push_id.as_ref(), Some(&push_id)); + assert_eq!(report.journal_status, Some(JournalStatus::Applying)); + let error = report.error.expect("guardrail error"); + assert_eq!(error.code, "guardrail"); + assert!(error.message.contains("ambiguous result")); +} + +#[test] +fn daemon_push_blocks_ambiguous_gmail_draft_move_send_after_outbox_rename() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let source_path = Path::new("outbox/Renamed Send.md"); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &cache_path, + "---\nloc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Renamed Send\nsubject: Send now subject\nto: [\"ann@example.com\"]\n---\nEdited body before sending.\n", + ) + .expect("cache file"); + + let draft_folder_id = RemoteId::new("gmail-folder:draft"); + let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + outbox_folder_id.clone(), + EntityKind::Directory, + "outbox", + "outbox", + )) + .expect("save outbox folder"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Original", + source_path, + ) + .with_hydration(HydrationState::Dirty), + ) + .expect("save moved draft"); + store + .save_shadow( + &fixture.mount_id, + ShadowDocument::from_synced_body( + draft_remote_id.clone(), + "Original body.\n", + 1, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter("loc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Original\nsubject: Original subject\nto: [\"ann@example.com\"]\n"), + ) + .expect("save shadow"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(outbox_folder_id.clone()), + original_path: Some(PathBuf::from("draft/Original.md")), + projected_path: source_path.to_path_buf(), + title: "Renamed Send".to_string(), + content_path: Some(cache_path), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + + let ambiguous_plan = PushPlan::new( + vec![draft_remote_id.clone()], + vec![ + PushOperation::MoveEntity { + entity_id: draft_remote_id.clone(), + new_parent_id: outbox_folder_id, + new_parent_kind: EntityKind::Directory, + new_title: "Send Now".to_string(), + projected_path: PathBuf::from("outbox/Send Now.md"), + }, + PushOperation::UpdateProperties { + entity_id: draft_remote_id.clone(), + properties: BTreeMap::from([( + "subject".to_string(), + PropertyValue::String("Send now subject".to_string()), + )]), + }, + PushOperation::UpdateEntityBody { + entity_id: draft_remote_id, + body: "Edited body before sending.\n".to_string(), + }, + ], + ); + let push_id = PushId("push-ambiguous-gmail-draft-send-renamed".to_string()); + store + .append_journal(JournalEntry::new( + push_id.clone(), + fixture.mount_id.clone(), + ambiguous_plan.affected_entities.clone(), + ambiguous_plan, + JournalStatus::Applying, + )) + .expect("append applying journal"); + let source = FakePushSource::default(); + + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("retry ambiguous renamed gmail draft send"); + + assert_eq!(report.action, PushJobAction::Failed); + assert_eq!( + source.applied_count(), + 0, + "retry must not resend renamed Gmail draft" + ); + assert_eq!(report.push_id.as_ref(), Some(&push_id)); + let error = report.error.expect("guardrail error"); + assert_eq!(error.code, "guardrail"); + assert!(error.message.contains("ambiguous result")); +} + +#[test] +fn daemon_push_blocks_ambiguous_gmail_draft_move_send_inside_larger_batch() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let first_path = Path::new("outbox/Send One.md"); + let second_path = Path::new("outbox/Send Two.md"); + let first_cache = + virtual_fs_content_path(&state_root, &fixture.mount_id, first_path).expect("cache path"); + let second_cache = + virtual_fs_content_path(&state_root, &fixture.mount_id, second_path).expect("cache path"); + fs::create_dir_all(first_cache.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &first_cache, + "---\nloc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Send One\nsubject: Send one subject\nto: [\"ann@example.com\"]\n---\nFirst body.\n", + ) + .expect("first cache file"); + fs::write( + &second_cache, + "---\nloc:\n id: gmail-draft:draft-2\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Send Two\nsubject: Send two subject\nto: [\"bob@example.com\"]\n---\nSecond body.\n", + ) + .expect("second cache file"); + + let draft_folder_id = RemoteId::new("gmail-folder:draft"); + let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); + let first_remote_id = RemoteId::new("gmail-draft:draft-1"); + let second_remote_id = RemoteId::new("gmail-draft:draft-2"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + outbox_folder_id.clone(), + EntityKind::Directory, + "outbox", + "outbox", + )) + .expect("save outbox folder"); + for (remote_id, title, path, body) in [ + ( + first_remote_id.clone(), + "Original One", + first_path, + "Original one body.\n", + ), + ( + second_remote_id.clone(), + "Original Two", + second_path, + "Original two body.\n", + ), + ] { + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + remote_id.clone(), + EntityKind::Page, + title, + path, + ) + .with_hydration(HydrationState::Dirty), + ) + .expect("save moved draft"); + store + .save_shadow( + &fixture.mount_id, + ShadowDocument::from_synced_body(remote_id.clone(), body, 1, [RemoteId::new("body-1")]) + .expect("shadow") + .with_frontmatter(format!("loc:\n id: {}\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: {}\nsubject: {}\nto: [\"ann@example.com\"]\n", remote_id.0, title, title)), + ) + .expect("save shadow"); + } + for (local_id, remote_id, path, cache, title) in [ + ( + "move:draft-1-to-outbox", + first_remote_id.clone(), + first_path, + first_cache, + "Send One", + ), + ( + "move:draft-2-to-outbox", + second_remote_id.clone(), + second_path, + second_cache, + "Send Two", + ), + ] { + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: local_id.to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(remote_id), + parent_remote_id: Some(outbox_folder_id.clone()), + original_path: Some(PathBuf::from("draft/Original.md")), + projected_path: path.to_path_buf(), + title: title.to_string(), + content_path: Some(cache), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + } + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + + let ambiguous_plan = PushPlan::new( + vec![first_remote_id.clone()], + vec![PushOperation::MoveEntity { + entity_id: first_remote_id.clone(), + new_parent_id: outbox_folder_id, + new_parent_kind: EntityKind::Directory, + new_title: "Send One".to_string(), + projected_path: first_path.to_path_buf(), + }], + ); + let push_id = PushId("push-ambiguous-gmail-draft-send-batch".to_string()); + store + .append_journal(JournalEntry::new( + push_id.clone(), + fixture.mount_id.clone(), + ambiguous_plan.affected_entities.clone(), + ambiguous_plan, + JournalStatus::Applying, + )) + .expect("append applying journal"); + let source = FakePushSource::default(); + + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join("outbox"), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + Some(&state_root), + ) + .expect("retry ambiguous batch gmail draft send"); + + assert_eq!(report.action, PushJobAction::Failed); + assert_eq!( + source.applied_count(), + 0, + "retry must not resend Gmail draft batch" + ); + assert_eq!(report.push_id.as_ref(), Some(&push_id)); + let error = report.error.expect("guardrail error"); + assert_eq!(error.code, "guardrail"); + assert!(error.message.contains("ambiguous result")); +} + +#[test] +fn daemon_push_blocks_complete_effect_gmail_draft_send_overlap_inside_larger_batch() { + let fixture = PushFixture::new(); + let state_root = fixture.root.join(".state"); + let first_path = Path::new("outbox/Send One.md"); + let second_path = Path::new("outbox/Send Two.md"); + let first_cache = + virtual_fs_content_path(&state_root, &fixture.mount_id, first_path).expect("cache path"); + let second_cache = + virtual_fs_content_path(&state_root, &fixture.mount_id, second_path).expect("cache path"); + fs::create_dir_all(first_cache.parent().expect("cache parent")).expect("cache parent"); + fs::write( + &first_cache, + "---\nloc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Send One\nsubject: Send one subject\nto: [\"ann@example.com\"]\n---\nFirst body.\n", + ) + .expect("first cache file"); + fs::write( + &second_cache, + "---\nloc:\n id: gmail-draft:draft-2\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Send Two\nsubject: Send two subject\nto: [\"bob@example.com\"]\n---\nSecond body.\n", + ) + .expect("second cache file"); + + let draft_folder_id = RemoteId::new("gmail-folder:draft"); + let outbox_folder_id = RemoteId::new("gmail-folder:outbox"); + let first_remote_id = RemoteId::new("gmail-draft:draft-1"); + let second_remote_id = RemoteId::new("gmail-draft:draft-2"); + let mut store = InMemoryStateStore::new(); + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + draft_folder_id.clone(), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + outbox_folder_id.clone(), + EntityKind::Directory, + "outbox", + "outbox", + )) + .expect("save outbox folder"); + for (remote_id, title, path, body) in [ + ( + first_remote_id.clone(), + "Original One", + first_path, + "Original one body.\n", + ), + ( + second_remote_id.clone(), + "Original Two", + second_path, + "Original two body.\n", + ), + ] { + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + remote_id.clone(), + EntityKind::Page, + title, + path, + ) + .with_hydration(HydrationState::Dirty), + ) + .expect("save moved draft"); + store + .save_shadow( + &fixture.mount_id, + ShadowDocument::from_synced_body(remote_id.clone(), body, 1, [RemoteId::new("body-1")]) + .expect("shadow") + .with_frontmatter(format!("loc:\n id: {}\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: {}\nsubject: {}\nto: [\"ann@example.com\"]\n", remote_id.0, title, title)), + ) + .expect("save shadow"); + } + for (local_id, remote_id, path, cache, title) in [ + ( + "move:draft-1-to-outbox", + first_remote_id.clone(), + first_path, + first_cache, + "Send One", + ), + ( + "move:draft-2-to-outbox", + second_remote_id.clone(), + second_path, + second_cache, + "Send Two", + ), + ] { + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: local_id.to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(remote_id), + parent_remote_id: Some(outbox_folder_id.clone()), + original_path: Some(PathBuf::from("draft/Original.md")), + projected_path: path.to_path_buf(), + title: title.to_string(), + content_path: Some(cache), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move mutation"); + } + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + + let sent_remote_id = RemoteId::new("gmail-message:sent-1"); + let applied_plan = PushPlan::new( + vec![first_remote_id.clone()], + vec![PushOperation::MoveEntity { + entity_id: first_remote_id.clone(), + new_parent_id: outbox_folder_id, + new_parent_kind: EntityKind::Directory, + new_title: "Send One".to_string(), + projected_path: first_path.to_path_buf(), }], ); - let push_id = PushId("push-ambiguous-gmail-outbox".to_string()); + let push_id = PushId("push-complete-gmail-draft-send-batch-overlap".to_string()); store - .append_journal(JournalEntry::new( - push_id.clone(), - fixture.mount_id.clone(), - plan.affected_entities.clone(), - plan, - JournalStatus::Applying, - )) - .expect("append applying journal"); - let source = FakePushSource::default(); + .append_journal( + JournalEntry::new( + push_id.clone(), + fixture.mount_id.clone(), + applied_plan.affected_entities.clone(), + applied_plan, + JournalStatus::Applied, + ) + .with_apply_effects(vec![ + JournalApplyEffect::ArchivedEntity { + operation_id: PushOperationId("send-draft-archive".to_string()), + operation_index: 0, + entity_id: first_remote_id.clone(), + }, + JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("send-draft-sent".to_string()), + operation_index: 0, + parent_id: RemoteId::new("gmail-folder:sent"), + entity_id: sent_remote_id, + }, + ]), + ) + .expect("append applied journal"); + let source = FakePushSource::default() + .with_created_entity( + first_remote_id, + rendered_entity("gmail-draft:draft-1", "Original one body."), + ) + .with_created_entity( + second_remote_id, + rendered_entity("gmail-draft:draft-2", "Original two body."), + ); let report = execute_push_job_with_content_root( &mut store, PushJob { - target_path: fixture.root.join(source_path), + target_path: fixture.root.join("outbox"), assume_yes: true, - confirm_dangerous: false, + confirm_dangerous: true, }, &source, Some(&state_root), ) - .expect("retry ambiguous gmail push"); + .expect("retry overlapping batch gmail draft send"); assert_eq!(report.action, PushJobAction::Failed); - assert_eq!(source.applied_count(), 0, "retry must not resend Gmail"); + assert_eq!( + source.applied_count(), + 0, + "retry must not apply a broader batch containing an already-sent Gmail draft" + ); assert_eq!(report.push_id.as_ref(), Some(&push_id)); - assert_eq!(report.journal_status, Some(JournalStatus::Applying)); let error = report.error.expect("guardrail error"); assert_eq!(error.code, "guardrail"); - assert!(error.message.contains("ambiguous result")); + assert!(error.message.contains("already applied")); } #[test] @@ -3053,6 +4367,120 @@ fn moved_entity_reconciliation_requires_effect_and_changed_id_and_retains_intent } } +#[test] +fn non_gmail_move_with_gmail_shaped_ids_still_requires_moved_entity_effect() { + let (fixture, state_root, mut store) = pending_move_execution_store_for_connector("notion"); + let draft_remote_id = RemoteId::new("gmail-draft:draft-1"); + let source_path = Path::new("Team B/Roadmap.md"); + let cache_path = + virtual_fs_content_path(&state_root, &fixture.mount_id, source_path).expect("cache path"); + fs::write( + &cache_path, + render_canonical_markdown(&CanonicalDocument::new( + "loc:\n id: gmail-draft:draft-1\n type: page\n synced_at: now\n remote_edited_at: now\ntitle: Roadmap\n", + markdown_body("Old body."), + )), + ) + .expect("write colliding cache"); + store + .delete_entity(&fixture.mount_id, &fixture.remote_id) + .expect("delete default moved entity"); + store + .delete_virtual_mutation(&fixture.mount_id, "move:page-1") + .expect("delete default move"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new("gmail-folder:outbox"), + EntityKind::Directory, + "outbox", + "Outbox", + )) + .expect("save colliding outbox parent"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new("gmail-folder:sent"), + EntityKind::Directory, + "sent", + "Sent", + )) + .expect("save colliding sent parent"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Roadmap", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at("2026-06-10T00:00:00Z"), + ) + .expect("save gmail-shaped entity"); + store + .save_shadow( + &fixture.mount_id, + shadow(draft_remote_id.as_str(), "Old body."), + ) + .expect("save gmail-shaped shadow"); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:gmail-draft:draft-1".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(draft_remote_id.clone()), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(PathBuf::from("Team A/Roadmap.md")), + projected_path: source_path.to_path_buf(), + title: "Roadmap".to_string(), + content_path: None, + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save gmail-shaped move"); + let source = FakePushSource::default() + .with_created_entity( + RemoteId::new("gmail-draft:draft-1"), + rendered_entity("gmail-draft:draft-1", "Old body."), + ) + .with_created_entity( + RemoteId::new("gmail-message:sent-1"), + rendered_entity("gmail-message:sent-1", "Sent body."), + ) + .with_apply_effects(vec![ + JournalApplyEffect::ArchivedEntity { + operation_id: PushOperationId("op-move".to_string()), + operation_index: 0, + entity_id: draft_remote_id, + }, + JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("op-move".to_string()), + operation_index: 0, + parent_id: RemoteId::new("gmail-folder:sent"), + entity_id: RemoteId::new("gmail-message:sent-1"), + }, + ]) + .with_changed_remote_ids(Vec::new()); + + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(source_path), + assume_yes: true, + confirm_dangerous: true, + }, + &source, + Some(&state_root), + ) + .expect("execute non-gmail colliding move"); + + assert_eq!(report.action, PushJobAction::Failed); + let journal = store.list_journal().unwrap(); + assert!(matches!(journal[0].status, JournalStatus::Failed(_))); +} + #[test] fn moved_entity_fetch_failure_resumes_same_journal_without_reapplying() { let (fixture, state_root, mut store) = pending_move_execution_store(); @@ -3965,6 +5393,408 @@ fn rendered_gmail_entity( } } +fn gmail_draft_store( + fixture: &PushFixture, + connector: &GmailConnector, + draft_remote_id: &RemoteId, + source_path: &Path, +) -> InMemoryStateStore { + let mut store = InMemoryStateStore::new(); + seed_gmail_draft_store(&mut store, fixture, connector, draft_remote_id, source_path); + store +} + +fn seed_gmail_draft_store( + store: &mut S, + fixture: &PushFixture, + connector: &GmailConnector, + draft_remote_id: &RemoteId, + source_path: &Path, +) where + S: MountRepository + EntityRepository + ShadowRepository, +{ + store + .save_mount( + MountConfig::new(fixture.mount_id.clone(), "gmail", &fixture.root) + .projection(ProjectionMode::LinuxFuse), + ) + .expect("save mount"); + for (remote_id, title, path) in [ + ("gmail-folder:draft", "draft", "draft"), + ("gmail-folder:outbox", "outbox", "outbox"), + ("gmail-folder:sent", "sent", "sent"), + ] { + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new(remote_id), + EntityKind::Directory, + title, + path, + )) + .expect("save gmail folder"); + } + let rendered = connector + .fetch_render(&locality_core::hydration::HydrationRequest::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + source_path.to_path_buf(), + HydrationState::Hydrated, + locality_core::hydration::HydrationReason::ExplicitPull, + )) + .expect("render remote draft"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + draft_remote_id.clone(), + EntityKind::Page, + "Remote Draft", + source_path, + ) + .with_hydration(HydrationState::Dirty) + .with_remote_edited_at( + rendered + .remote_edited_at + .as_deref() + .expect("draft remote version"), + ), + ) + .expect("save draft entity"); + store + .save_shadow(&fixture.mount_id, rendered.shadow) + .expect("save draft shadow"); +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct RecordingGmailCalls { + call_log: Vec, + updated_drafts: Vec<(String, String)>, + sent_drafts: Vec, +} + +#[derive(Debug)] +struct RecordingGmailApi { + state: Mutex, +} + +#[derive(Debug)] +struct RecordingGmailState { + calls: RecordingGmailCalls, + drafts: BTreeMap, + sent_messages: BTreeMap, + sent_fetch_failures: BTreeMap, +} + +impl RecordingGmailApi { + fn new() -> Self { + Self { + state: Mutex::new(RecordingGmailState { + calls: RecordingGmailCalls::default(), + drafts: BTreeMap::from([( + "draft-1".to_string(), + GmailDraft { + id: "draft-1".to_string(), + message: gmail_test_message( + "draft-message-1", + &["DRAFT"], + "Remote Draft", + "ann@example.com", + "Remote body.\n", + "1720900000000", + ), + }, + )]), + sent_messages: BTreeMap::new(), + sent_fetch_failures: BTreeMap::new(), + }), + } + } + + fn with_sent_fetch_failures(mut self, remote_id: &RemoteId, failures: usize) -> Self { + self.state + .get_mut() + .expect("gmail state") + .sent_fetch_failures + .insert(remote_id.as_str().to_string(), failures); + self + } + + fn calls(&self) -> RecordingGmailCalls { + self.state.lock().expect("gmail state").calls.clone() + } +} + +impl GmailApi for RecordingGmailApi { + fn list_messages( + &self, + _label_id: &str, + _max_results: u32, + _page_token: Option<&str>, + _query: Option<&str>, + ) -> LocalityResult { + Ok(GmailMessageList::default()) + } + + fn list_threads( + &self, + _label_id: &str, + _max_results: u32, + _page_token: Option<&str>, + _query: Option<&str>, + ) -> LocalityResult { + Ok(GmailThreadList::default()) + } + + fn get_message_metadata(&self, message_id: &str) -> LocalityResult { + self.get_message_full(message_id) + } + + fn get_message_full(&self, message_id: &str) -> LocalityResult { + let mut state = self.state.lock().expect("gmail state"); + if let Some(remaining) = state.sent_fetch_failures.get_mut(message_id) + && *remaining > 0 + { + *remaining -= 1; + return Err(LocalityError::InvalidState( + "injected gmail sent readback failure".to_string(), + )); + } + state + .sent_messages + .get(message_id) + .cloned() + .ok_or_else(|| LocalityError::InvalidState(format!("missing message `{message_id}`"))) + } + + fn get_thread_metadata(&self, thread_id: &str) -> LocalityResult { + Ok(GmailThread { + id: thread_id.to_string(), + history_id: Some("h1".to_string()), + messages: Vec::new(), + }) + } + + fn get_thread_full(&self, thread_id: &str) -> LocalityResult { + self.get_thread_metadata(thread_id) + } + + fn get_attachment( + &self, + _message_id: &str, + _attachment_id: &str, + ) -> LocalityResult { + Ok(GmailMessagePartBody::default()) + } + + fn list_drafts( + &self, + _max_results: u32, + _page_token: Option<&str>, + _query: Option<&str>, + ) -> LocalityResult { + Ok(GmailDraftList::default()) + } + + fn get_draft_full(&self, draft_id: &str) -> LocalityResult { + self.state + .lock() + .expect("gmail state") + .drafts + .get(draft_id) + .cloned() + .ok_or_else(|| LocalityError::InvalidState(format!("missing draft `{draft_id}`"))) + } + + fn create_draft(&self, _request: GmailDraftCreateRequest) -> LocalityResult { + Err(LocalityError::InvalidState( + "unexpected draft create".to_string(), + )) + } + + fn update_draft( + &self, + draft_id: &str, + request: GmailDraftUpdateRequest, + ) -> LocalityResult { + let mut state = self.state.lock().expect("gmail state"); + state + .calls + .call_log + .push(format!("update_draft:{draft_id}")); + state + .calls + .updated_drafts + .push((draft_id.to_string(), request.message.raw.clone())); + let updated = GmailDraft { + id: draft_id.to_string(), + message: gmail_message_from_raw_mime( + &format!("updated-draft-message-{draft_id}"), + &["DRAFT"], + &request.message.raw, + "1720900000001", + ), + }; + state.drafts.insert(draft_id.to_string(), updated.clone()); + Ok(updated) + } + + fn send_message(&self, _request: GmailMessageSendRequest) -> LocalityResult { + Err(LocalityError::InvalidState( + "unexpected message send".to_string(), + )) + } + + fn send_draft(&self, request: GmailDraftSendRequest) -> LocalityResult { + let mut state = self.state.lock().expect("gmail state"); + state + .calls + .call_log + .push(format!("send_draft:{}", request.id)); + state.calls.sent_drafts.push(request.id.clone()); + let draft = state.drafts.remove(&request.id).ok_or_else(|| { + LocalityError::InvalidState(format!("missing draft `{}`", request.id)) + })?; + let subject = gmail_header(&draft.message, "subject").unwrap_or("Sent Draft"); + let to = gmail_header(&draft.message, "to").unwrap_or("user@example.com"); + let body = gmail_message_body(&draft.message); + let sent = gmail_test_message( + "gmail-message:sent-1", + &["SENT"], + subject, + to, + &body, + "1720900001000", + ); + state + .sent_messages + .insert("gmail-message:sent-1".to_string(), sent.clone()); + Ok(sent) + } +} + +fn gmail_message_from_raw_mime( + id: &str, + labels: &[&str], + raw: &str, + internal_date: &str, +) -> GmailMessage { + let mime = decode_raw_mime(raw); + let (headers, body) = split_raw_mime(&mime); + let subject = headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case("subject")) + .map(|header| header.value.as_str()) + .unwrap_or("(no subject)"); + let to = headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case("to")) + .map(|header| header.value.as_str()) + .unwrap_or(""); + gmail_test_message(id, labels, subject, to, &body, internal_date) +} + +fn split_raw_mime(mime: &str) -> (Vec, String) { + let normalized = mime.replace("\r\n", "\n"); + let (head, body) = normalized + .split_once("\n\n") + .unwrap_or((normalized.as_str(), "")); + let headers = head + .lines() + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + Some(GmailHeader { + name: name.trim().to_string(), + value: value.trim().to_string(), + }) + }) + .collect(); + (headers, body.to_string()) +} + +fn decode_raw_mime(raw: &str) -> String { + String::from_utf8( + URL_SAFE_NO_PAD + .decode(raw.as_bytes()) + .or_else(|_| URL_SAFE.decode(raw.as_bytes())) + .expect("decode raw mime"), + ) + .expect("raw mime utf8") +} + +fn gmail_test_message( + id: &str, + labels: &[&str], + subject: &str, + to: &str, + body: &str, + internal_date: &str, +) -> GmailMessage { + GmailMessage { + id: id.to_string(), + thread_id: Some(format!("{id}-thread")), + label_ids: labels.iter().map(|label| (*label).to_string()).collect(), + snippet: None, + internal_date: Some(internal_date.to_string()), + payload: Some(GmailMessagePart { + part_id: None, + mime_type: Some("text/plain".to_string()), + filename: None, + headers: vec![ + GmailHeader { + name: "From".to_string(), + value: "Ann ".to_string(), + }, + GmailHeader { + name: "To".to_string(), + value: to.to_string(), + }, + GmailHeader { + name: "Subject".to_string(), + value: subject.to_string(), + }, + GmailHeader { + name: "Date".to_string(), + value: "Tue, 14 Jul 2026 09:30:00 +0000".to_string(), + }, + ], + body: Some(GmailMessagePartBody { + size: Some(body.len() as u64), + data: Some(URL_SAFE_NO_PAD.encode(body.as_bytes())), + attachment_id: None, + }), + parts: Vec::new(), + }), + raw: None, + } +} + +fn gmail_header<'a>(message: &'a GmailMessage, name: &str) -> Option<&'a str> { + message + .payload + .as_ref()? + .headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case(name)) + .map(|header| header.value.as_str()) +} + +fn gmail_message_body(message: &GmailMessage) -> String { + let data = message + .payload + .as_ref() + .and_then(|part| part.body.as_ref()) + .and_then(|body| body.data.as_deref()) + .unwrap_or(""); + String::from_utf8( + URL_SAFE_NO_PAD + .decode(data.as_bytes()) + .or_else(|_| URL_SAFE.decode(data.as_bytes())) + .expect("decode gmail body"), + ) + .expect("gmail body utf8") +} + fn rendered_google_calendar_entity( remote_id: &str, summary: &str, diff --git a/crates/localityd/tests/push_preparation.rs b/crates/localityd/tests/push_preparation.rs index 74ef61b2..ddd15759 100644 --- a/crates/localityd/tests/push_preparation.rs +++ b/crates/localityd/tests/push_preparation.rs @@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use locality_core::LocalityError; use locality_core::model::{EntityKind, HydrationState, MountId, RemoteId}; -use locality_core::planner::{PropertyValue, PushOperation}; +use locality_core::planner::{GuardrailDecision, PropertyValue, PushOperation}; use locality_core::push::PushPipelineAction; use locality_core::shadow::{MarkdownBlockKind, ShadowDocument}; use locality_core::validation::ValidationReport; @@ -1370,7 +1370,7 @@ fn prepare_stale_pending_move_rechecks_source_and_mount_write_policy() { .save_entity( EntityRecord::new( fixture.mount_id.clone(), - RemoteId::new("draft-1"), + RemoteId::new("gmail-draft:draft-1"), EntityKind::Page, "Draft subject", "outbox/ENG-1.md", @@ -1382,7 +1382,7 @@ fn prepare_stale_pending_move_rechecks_source_and_mount_write_policy() { .save_shadow( &fixture.mount_id, ShadowDocument::from_synced_body( - RemoteId::new("draft-1"), + RemoteId::new("gmail-draft:draft-1"), "Draft body", 8, [RemoteId::new("body-1")], @@ -1395,7 +1395,7 @@ fn prepare_stale_pending_move_rechecks_source_and_mount_write_policy() { mount_id: fixture.mount_id.clone(), local_id: "move:draft-1-to-send".to_string(), mutation_kind: VirtualMutationKind::Move, - target_remote_id: Some(RemoteId::new("draft-1")), + target_remote_id: Some(RemoteId::new("gmail-draft:draft-1")), parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), original_path: Some(PathBuf::from("draft/ENG-1.md")), projected_path: PathBuf::from("outbox/ENG-1.md"), @@ -1413,13 +1413,17 @@ fn prepare_stale_pending_move_rechecks_source_and_mount_write_policy() { &LocalSourceValidator, ) .expect("prepare stale Gmail outbound move"); - assert_eq!(prepared.pipeline.action, PushPipelineAction::FixValidation); - assert!(prepared.pipeline.plan.is_none()); - assert!(prepared.pipeline.validation.issues.iter().any(|issue| { - issue.code == "source_move_parent_read_only" - && issue.message - == "Gmail moves are not supported; create a new file directly under draft/ or outbox/" - })); + assert_eq!(prepared.pipeline.action, PushPipelineAction::ConfirmPlan); + assert_eq!( + prepared.pipeline.plan.expect("plan").operations, + vec![PushOperation::MoveEntity { + entity_id: RemoteId::new("gmail-draft:draft-1"), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Draft subject".to_string(), + projected_path: PathBuf::from("outbox/ENG-1.md"), + }] + ); let (fixture, mut store) = linear_move_store(None, true); store @@ -2163,6 +2167,194 @@ fn prepare_gmail_send_create_keeps_subject_and_recipients_as_properties() { } } +#[test] +fn prepare_gmail_draft_update_plans_properties_and_body_without_create() { + let fixture = PrepareFixture::new(); + let mut store = fixture.store("gmail"); + save_gmail_folder(&fixture, &mut store, "gmail-folder:draft", "draft", "draft"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new("gmail-draft:draft-1"), + EntityKind::Page, + "Original", + "draft/Original.md", + ) + .with_hydration(HydrationState::Hydrated), + ) + .expect("save draft"); + store + .save_shadow( + &fixture.mount_id, + ShadowDocument::from_synced_body( + RemoteId::new("gmail-draft:draft-1"), + "Original body.\n", + 8, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter(&gmail_draft_frontmatter( + "gmail-draft:draft-1", + "Original", + "Original subject", + &["ann@example.com"], + )), + ) + .expect("save shadow"); + let draft_path = fixture.write_raw( + "draft/Original.md", + &gmail_draft_document( + "gmail-draft:draft-1", + "Original", + "Edited subject", + &["beth@example.com"], + "Edited body.\n", + ), + ); + + let prepared = prepare_push( + &store, + &job(draft_path), + Some(&fixture.state_root), + &LocalSourceValidator, + ) + .expect("prepare draft update"); + + assert_eq!(prepared.pipeline.action, PushPipelineAction::ConfirmPlan); + assert_eq!(prepared.pipeline.guardrail, GuardrailDecision::Proceed); + assert!(prepared.pipeline.validation.is_clean()); + let plan = prepared.pipeline.plan.expect("plan"); + assert_eq!( + plan.operations, + vec![ + PushOperation::UpdateProperties { + entity_id: RemoteId::new("gmail-draft:draft-1"), + properties: BTreeMap::from([ + ( + "subject".to_string(), + PropertyValue::String("Edited subject".to_string()), + ), + ( + "to".to_string(), + PropertyValue::List(vec!["beth@example.com".to_string()]), + ), + ]), + }, + PushOperation::UpdateEntityBody { + entity_id: RemoteId::new("gmail-draft:draft-1"), + body: "Edited body.\n".to_string(), + }, + ] + ); +} + +#[test] +fn prepare_gmail_draft_move_to_outbox_with_edits_orders_move_properties_and_body() { + let fixture = PrepareFixture::new(); + let mut store = fixture.virtual_store("gmail"); + save_gmail_folder(&fixture, &mut store, "gmail-folder:draft", "draft", "draft"); + save_gmail_folder( + &fixture, + &mut store, + "gmail-folder:outbox", + "outbox", + "outbox", + ); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new("gmail-draft:draft-1"), + EntityKind::Page, + "Original", + "outbox/Send Now.md", + ) + .with_hydration(HydrationState::Dirty), + ) + .expect("save moved draft"); + store + .save_shadow( + &fixture.mount_id, + ShadowDocument::from_synced_body( + RemoteId::new("gmail-draft:draft-1"), + "Original body.\n", + 8, + [RemoteId::new("body-1")], + ) + .expect("shadow") + .with_frontmatter(&gmail_draft_frontmatter( + "gmail-draft:draft-1", + "Original", + "Original subject", + &["ann@example.com"], + )), + ) + .expect("save shadow"); + let cache = fixture.write_virtual_page( + "outbox/Send Now.md", + &gmail_draft_document( + "gmail-draft:draft-1", + "Send Now", + "Send now subject", + &["ann@example.com"], + "Edited body before sending.\n", + ), + ); + store + .save_virtual_mutation(VirtualMutationRecord { + mount_id: fixture.mount_id.clone(), + local_id: "move:draft-1-to-outbox".to_string(), + mutation_kind: VirtualMutationKind::Move, + target_remote_id: Some(RemoteId::new("gmail-draft:draft-1")), + parent_remote_id: Some(RemoteId::new("gmail-folder:outbox")), + original_path: Some(PathBuf::from("draft/Original.md")), + projected_path: PathBuf::from("outbox/Send Now.md"), + title: "Send Now".to_string(), + content_path: Some(cache), + created_at: "2026-06-12T00:00:00Z".to_string(), + updated_at: "2026-06-12T00:00:00Z".to_string(), + }) + .expect("save move"); + fs::create_dir_all(fixture.root.join("outbox")).expect("visible outbox folder"); + + let prepared = prepare_push( + &store, + &job(fixture.root.join("outbox/Send Now.md")), + Some(&fixture.state_root), + &LocalSourceValidator, + ) + .expect("prepare moved draft"); + + assert_eq!(prepared.pipeline.action, PushPipelineAction::ConfirmPlan); + assert_eq!(prepared.pipeline.guardrail, GuardrailDecision::Proceed); + assert!(prepared.pipeline.validation.is_clean()); + let plan = prepared.pipeline.plan.expect("plan"); + assert_eq!( + plan.operations, + vec![ + PushOperation::MoveEntity { + entity_id: RemoteId::new("gmail-draft:draft-1"), + new_parent_id: RemoteId::new("gmail-folder:outbox"), + new_parent_kind: EntityKind::Directory, + new_title: "Send Now".to_string(), + projected_path: PathBuf::from("outbox/Send Now.md"), + }, + PushOperation::UpdateProperties { + entity_id: RemoteId::new("gmail-draft:draft-1"), + properties: BTreeMap::from([( + "subject".to_string(), + PropertyValue::String("Send now subject".to_string()), + )]), + }, + PushOperation::UpdateEntityBody { + entity_id: RemoteId::new("gmail-draft:draft-1"), + body: "Edited body before sending.\n".to_string(), + }, + ] + ); +} + #[test] fn prepare_gmail_draft_create_accepts_subject_without_title() { let fixture = PrepareFixture::new(); @@ -2854,6 +3046,52 @@ fn canonical_markdown(remote_id: &str, body: &str) -> String { ) } +fn gmail_draft_document( + remote_id: &str, + title: &str, + subject: &str, + to: &[&str], + body: &str, +) -> String { + format!( + "---\n{}---\n{body}", + gmail_draft_frontmatter(remote_id, title, subject, to) + ) +} + +fn gmail_draft_frontmatter(remote_id: &str, title: &str, subject: &str, to: &[&str]) -> String { + let recipients = to + .iter() + .map(|recipient| format!("\"{recipient}\"")) + .collect::>() + .join(", "); + format!( + "loc:\n id: {remote_id}\n type: page\n connector: gmail\n synced_at: now\n remote_edited_at: now\ntitle: {title}\nsubject: {subject}\nto: [{recipients}]\ncc: []\nbcc: []\n" + ) +} + +fn save_gmail_folder( + fixture: &PrepareFixture, + store: &mut InMemoryStateStore, + remote_id: &str, + title: &str, + path: &str, +) { + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new(remote_id), + EntityKind::Directory, + title, + path, + ) + .with_hydration(HydrationState::Stub) + .with_remote_edited_at(format!("folder:{path}")), + ) + .expect("save Gmail folder"); +} + fn markdown_href(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } diff --git a/crates/localityd/tests/source_descriptor.rs b/crates/localityd/tests/source_descriptor.rs index 61b3d1d8..e8f8eb7c 100644 --- a/crates/localityd/tests/source_descriptor.rs +++ b/crates/localityd/tests/source_descriptor.rs @@ -2,6 +2,7 @@ use locality_connector::Connector; use locality_connector::oauth_broker::OAuthBrokerToken; use locality_core::canonical::parse_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; +use locality_core::planner::PushOperationKind; use locality_core::push::BodyDiffMode; use locality_core::shadow::ShadowDocument; use locality_core::validation::ValidationIssue; @@ -125,14 +126,16 @@ fn gmail_descriptor_comes_from_registry() { Gmail facts: - This mount projects Gmail inbox/, sent/, draft/, and outbox/ folders. - inbox/ and sent/ are read-only mailbox history. +- draft/ contains remote Gmail drafts and local draft creates. Edit a remote draft there and push to update the Gmail draft. - Create a Markdown file directly under draft/ to create an unsent Gmail draft. -- Create a Markdown file directly under outbox/ to send immediately after explicit review and push. +- outbox/ is local-only send staging. Create a Markdown file directly under outbox/ to send immediately after explicit review and push. +- Move an existing remote draft from draft/ to outbox/ and push to send the updated draft. - Both outbound folders require `to` frontmatter and either `subject` or `title` frontmatter. -- Use outbox/ only when the user explicitly asks to send mail now; otherwise use draft/ for review in Gmail. +- Use outbox/ only when the user explicitly asks to send mail now; otherwise leave messages in draft/ for drafting and revision. - To inspect inbound attachments, first hydrate the message or thread Markdown by opening it or running `loc pull `. - Hydrated messages list attachments in YAML frontmatter under `gmail.attachments`; read `filename`, `mime_type`, `size`, `attachment_id`, and `path` from that list. - Open the attachment file at the listed `path`, relative to the mount root. Gmail attachment caches normally live under `.loc/gmail/attachments/...`; use the frontmatter path exactly. -- Gmail draft creation does not support outbound attachments yet. Outbox direct-send creation does not support outbound attachments yet either. Do not add `attachment` or `attachments` frontmatter to draft or outbox files. +- Gmail outbound attachments are not supported yet. Do not add `attachment` or `attachments` frontmatter to draft or outbox files. "; assert!( descriptor @@ -145,6 +148,7 @@ Gmail facts: descriptor.create_entity_parent_kinds(), &[EntityKind::Directory] ); + assert_eq!(descriptor.body_diff_mode(), BodyDiffMode::WholeEntity); } #[test] @@ -310,15 +314,38 @@ fn gmail_write_policy_allows_only_direct_draft_and_send_children() { !source_create_decision_for_parent_path(&mount, std::path::Path::new("outbox/nested")) .is_writable() ); +} + +#[test] +fn gmail_move_policy_allows_draft_to_direct_outbox_parent() { + let mut mount = MountConfig::new( + MountId::new("gmail-main"), + GMAIL_CONNECTOR_ID, + "/tmp/locality/gmail", + ); + mount.read_only = false; + assert!( - !source_move_decision_for_parent_path(&mount, std::path::Path::new("draft")).is_writable() + source_move_decision_for_parent_path(&mount, std::path::Path::new("outbox")).is_writable() + ); +} + +#[test] +fn gmail_move_policy_rejects_nested_outbox_parent() { + let mut mount = MountConfig::new( + MountId::new("gmail-main"), + GMAIL_CONNECTOR_ID, + "/tmp/locality/gmail", ); - let send_move_decision = - source_move_decision_for_parent_path(&mount, std::path::Path::new("outbox")); - assert!(!send_move_decision.is_writable()); + mount.read_only = false; + + let decision = + source_move_decision_for_parent_path(&mount, std::path::Path::new("outbox/nested")); + + assert!(!decision.is_writable()); assert_eq!( - send_move_decision.reason(), - Some("Gmail moves are not supported; create a new file directly under draft/ or outbox/") + decision.reason(), + Some("Gmail only supports moving an existing draft directly into outbox/ to send it") ); } @@ -530,7 +557,6 @@ fn source_descriptors_declare_canonical_title_rename_policy() { "notion", "google-docs", "google-calendar", - "gmail", "granola", "slack", "custom", @@ -545,10 +571,18 @@ fn source_descriptors_declare_canonical_title_rename_policy() { source_descriptor("linear").virtual_rename_policy(), VirtualRenamePolicy::PreserveCanonical ); + assert_eq!( + source_descriptor("gmail").virtual_rename_policy(), + VirtualRenamePolicy::PreserveCanonical + ); assert_eq!( source_descriptor("linear").body_diff_mode(), BodyDiffMode::WholeEntity ); + assert_eq!( + source_descriptor("gmail").body_diff_mode(), + BodyDiffMode::WholeEntity + ); } #[test] @@ -915,7 +949,7 @@ fn validate_gmail_create(path: &str, markdown: &str) -> Vec { .collect() } -fn validate_gmail_changed(path: &str, markdown: &str) -> Vec { +fn validate_gmail_changed_issues(path: &str, markdown: &str) -> Vec { let mount = gmail_mount(); let parsed = parse_canonical_markdown(markdown).expect("parse gmail markdown"); @@ -930,6 +964,10 @@ fn validate_gmail_changed(path: &str, markdown: &str) -> Vec { }) .expect("validate gmail changed") .issues +} + +fn validate_gmail_changed(path: &str, markdown: &str) -> Vec { + validate_gmail_changed_issues(path, markdown) .into_iter() .map(|issue| issue.code) .collect() @@ -1392,6 +1430,18 @@ fn resolving_gmail_mount_uses_active_oauth_connection_credentials() { panic!("expected gmail source"); }; assert_eq!(connector.config().access_token, "gmail-access-token"); + assert!(connector.capabilities().supports_entity_body_updates); + assert_eq!( + connector.supported_push_operations(), + [ + PushOperationKind::CreateEntity, + PushOperationKind::UpdateProperties, + PushOperationKind::UpdateEntityBody, + PushOperationKind::MoveEntity, + ] + .into_iter() + .collect::>() + ); } #[test] @@ -2110,6 +2160,142 @@ fn local_gmail_validator_blocks_nested_send_create() { assert_eq!(issues, vec!["gmail_create_outside_outbound_folder"]); } +#[test] +fn local_gmail_validator_allows_valid_changed_draft() { + let issues = validate_gmail_changed( + "draft/foo.md", + "---\nto: [\"user@example.com\"]\nsubject: Hello\n---\nBody\n", + ); + + assert!(issues.is_empty()); +} + +#[test] +fn local_gmail_validator_rejects_changed_nested_draft_and_outbox() { + for path in ["draft/nested/foo.md", "outbox/nested/foo.md"] { + let issues = validate_gmail_changed( + path, + "---\nto: [\"user@example.com\"]\nsubject: Hello\n---\nBody\n", + ); + + assert_eq!(issues, vec!["gmail_outbound_nested_unsupported"], "{path}"); + } +} + +#[test] +fn local_gmail_validator_rejects_changed_draft_without_to() { + let issues = validate_gmail_changed("draft/foo.md", "---\nsubject: Hello\n---\nBody\n"); + + assert_eq!(issues, vec!["gmail_draft_missing_to"]); +} + +#[test] +fn local_gmail_validator_rejects_changed_draft_without_subject() { + let issues = validate_gmail_changed( + "draft/foo.md", + "---\nto: [\"user@example.com\"]\nsubject: \"\"\n---\nBody\n", + ); + + assert_eq!(issues, vec!["gmail_draft_missing_subject"]); + + let issues = validate_gmail_changed( + "draft/foo.md", + "---\ntitle: Fallback title\nto: [\"user@example.com\"]\nsubject: \"\"\n---\nBody\n", + ); + + assert!(issues.is_empty()); +} + +#[test] +fn local_gmail_validator_rejects_changed_draft_with_attachments() { + for (field, markdown) in [ + ( + "attachment", + "---\nto: [\"user@example.com\"]\nsubject: Hello\nattachment: invoice.pdf\n---\nBody\n", + ), + ( + "attachments", + "---\nto: [\"user@example.com\"]\nsubject: Hello\nattachments: [\"invoice.pdf\"]\n---\nBody\n", + ), + ( + "gmail.attachment", + "---\nto: [\"user@example.com\"]\nsubject: Hello\ngmail:\n attachment: invoice.pdf\n---\nBody\n", + ), + ( + "gmail.attachments", + "---\nto: [\"user@example.com\"]\nsubject: Hello\ngmail:\n attachments:\n - filename: invoice.pdf\n---\nBody\n", + ), + ] { + let issues = validate_gmail_changed_issues("draft/foo.md", markdown); + + assert_eq!(issues.len(), 1, "{field}"); + assert_eq!(issues[0].code, "gmail_attachments_unsupported", "{field}"); + assert_eq!( + issues[0].suggested_fix.as_deref(), + Some("remove attachment frontmatter"), + "{field}" + ); + } +} + +#[test] +fn local_gmail_validator_allows_valid_changed_outbox() { + let issues = validate_gmail_changed( + "outbox/foo.md", + "---\nto: [\"user@example.com\"]\nsubject: Hello\n---\nBody\n", + ); + + assert!(issues.is_empty()); +} + +#[test] +fn local_gmail_validator_rejects_changed_outbox_without_to() { + let issues = validate_gmail_changed("outbox/foo.md", "---\nsubject: Hello\n---\nBody\n"); + + assert_eq!(issues, vec!["gmail_draft_missing_to"]); +} + +#[test] +fn local_gmail_validator_rejects_changed_outbox_without_subject() { + let issues = validate_gmail_changed( + "outbox/foo.md", + "---\nto: [\"user@example.com\"]\nsubject: \"\"\n---\nBody\n", + ); + + assert_eq!(issues, vec!["gmail_draft_missing_subject"]); + + let issues = validate_gmail_changed( + "outbox/foo.md", + "---\ntitle: Fallback title\nto: [\"user@example.com\"]\nsubject: \"\"\n---\nBody\n", + ); + + assert!(issues.is_empty()); +} + +#[test] +fn local_gmail_validator_rejects_changed_outbox_with_attachments() { + for (field, markdown) in [ + ( + "attachments", + "---\nto: [\"user@example.com\"]\nsubject: Hello\nattachments: [\"invoice.pdf\"]\n---\nBody\n", + ), + ( + "gmail.attachments", + "---\nto: [\"user@example.com\"]\nsubject: Hello\ngmail:\n attachments:\n - filename: invoice.pdf\n---\nBody\n", + ), + ] { + let issues = validate_gmail_changed_issues("outbox/foo.md", markdown); + + assert_eq!(issues.len(), 1, "{field}"); + assert_eq!(issues[0].code, "gmail_attachments_unsupported", "{field}"); + assert_eq!( + issues[0].suggested_fix.as_deref(), + Some("remove attachment frontmatter"), + "{field}" + ); + } +} + #[test] fn local_gmail_validator_blocks_changed_inbox_and_sent_items() { for path in ["inbox/message.md", "sent/message.md"] { diff --git a/docs/agent-guidance.md b/docs/agent-guidance.md index 88caf63d..5eeee74f 100644 --- a/docs/agent-guidance.md +++ b/docs/agent-guidance.md @@ -39,10 +39,12 @@ The skill tells agents: - Agents should use `loc mv ` for intentional page/file moves or renames in mounted Locality content, then inspect with `loc status ` or `loc diff `. - For Notion, agents should read the mount-local `AGENTS.md` for the concrete page and row creation contract. Prefer `loc create page --title "New Page" --parent ` for new pages, and add `--private` when the remote page should be created in Notion's Private section; manually, pages are directories, a new child page is created by writing `parent-page/new-page/page.md`, new page frontmatter needs `title: "..."`, and generated `loc:` identity frontmatter is omitted until Locality adds it after push. - For Calendar, outbound operations use draft folders for new events. -- For Gmail, outbound operations distinguish `draft/` for unsent drafts from `outbox/` for direct sends. Use `outbox/` only when the user explicitly asks to send now; otherwise use `draft/`. +- For Gmail, leave messages in `draft/` when the user asks to draft or revise. Use `outbox/` only when the user explicitly asks to send now. +- Moving an existing Gmail draft into `outbox/` sends that draft after applying local edits. - For Linear, supported edits include issue body/frontmatter changes and status moves; Slack and Granola are read-only. - If desktop Live Mode is on, agents should expect safe local edits and clean remote changes to sync in the background. They can inspect state with `loc live-mode status `, but should not run routine `loc pull` or `loc push` after every edit. - If the user asks the agent to sync, send, publish, update the source, or apply the edit remotely, the agent should not stop after local edits. The safe sequence is `loc diff `, then `loc push -y` for safe plans. +- If Live Mode is paused, conflicted, or review-needed, agents should inspect with `loc status ` and `loc diff ` before pushing. - Agents should also push when Live Mode pauses for review and the user approves the scoped plan. - If push reports that the remote changed since last sync, the recovery sequence is `loc pull `, resolve any inline conflict markers, rerun `loc diff `, then push again. - If the agent sandbox cannot execute the host `loc` CLI, it should use the MCP fallback tool named `loc` with CLI-style `argv` arguments. Locality installs the required local MCP credentials for supported agents. diff --git a/docs/cli.md b/docs/cli.md index 389e8091..7238e34f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -161,7 +161,7 @@ Google Docs mounts use Google Docs document access plus Drive `drive.file` and D Gmail OAuth uses `openid`, `email`, `profile`, `https://www.googleapis.com/auth/gmail.readonly`, and `https://www.googleapis.com/auth/gmail.compose`. No broader Gmail account scope is required. -`loc mount gmail ` registers a Gmail mount. If `--connection` is omitted, the daemon resolves the mount through the only active Gmail connection at runtime; with multiple active Gmail connections, pass `--connection `. When `--mount-id` is omitted, Locality uses `gmail-main` when available. Gmail mounts project `inbox/`, `sent/`, `draft/`, and `outbox/` folders. `inbox/` and `sent/` are read-only; create a Markdown file directly under `draft/` to create an unsent Gmail UI draft on push, or directly under `outbox/` only for reviewed direct sends. +`loc mount gmail ` registers a Gmail mount. If `--connection` is omitted, the daemon resolves the mount through the only active Gmail connection at runtime; with multiple active Gmail connections, pass `--connection `. When `--mount-id` is omitted, Locality uses `gmail-main` when available. Gmail mounts project `inbox/`, `sent/`, `draft/`, and `outbox/` folders. `inbox/` and `sent/` are read-only. `draft/` contains remote Gmail drafts and local draft creates; editing a remote draft and pushing updates the Gmail draft. `outbox/` is local-only send staging for reviewed direct sends. Gmail mount options: @@ -719,9 +719,10 @@ dirty skip instead. For Gmail mounts, pull enumerates the recent 100 inbox messages, recent 100 sent messages, and recent 100 Gmail drafts by default. Date-window mounts page through all matching inbox messages, sent messages, and Gmail drafts. `draft/` -contains unsent Gmail drafts from Gmail and local draft-create pushes. `outbox/` -is not pulled from remote history; it is a local-only reviewed direct-send -staging folder, and successful pushes from `outbox/` reconcile to `sent/`. +contains remote Gmail drafts and local draft creates. Editing a remote draft and +pushing updates the Gmail draft. `outbox/` is not pulled from remote history; it +is a local-only reviewed direct-send staging folder, and successful pushes from +`outbox/` reconcile to `sent/`. The JSON report includes `via`, `enumerated`, `stubbed`, `hydrated`, and `skipped_dirty` counts. `via` is `daemon` when the Unix socket handled the job and `cli` when the command executed directly. @@ -934,13 +935,15 @@ The JSON report has the same validation, plan, degradation, guardrail, and stage Reports also include `via`, `push_id`, `journal_status`, changed/reconciled remote IDs, and `apply_effect_count` when execution starts. The Notion connector now applies the supported block and page-property write subset, local file-like media updates, block moves, and new database-row creation through the live API. Connector capability preflight runs before journaling, so unsupported operations return `unsupported_operations` without appending a journal. Once a journaled push starts, the daemon performs connector metadata checks and verifies the current Remote Tree render still matches the Synced Tree shadow before applying Local Tree edits. -For Gmail, `loc push` supports creating a new Markdown file directly under -`draft/` or `outbox/`. Push from `draft/` creates an unsent Gmail draft; send it -later from the Gmail UI. Push from `outbox/` directly sends the message and should -be used only after review when the user intends to send now. Gmail outbound -files require `to` frontmatter and either `subject` or `title`; `cc` and `bcc` -are optional. Nested outbound paths and edits or deletes in `inbox/` and -`sent/` are rejected. +For Gmail, `loc push` supports new Markdown files directly under `draft/` or +`outbox/`, editing existing remote drafts under `draft/`, and moving existing +remote drafts from `draft/` to `outbox/`. Push from `draft/` creates or updates +an unsent Gmail draft. Push from `outbox/` directly sends the message, and a +moved remote draft is sent after applying local edits. Use `outbox/` only after +review when the user intends to send now. Gmail outbound files require `to` +frontmatter and either `subject` or `title`; `cc` and `bcc` are optional. +Outbound attachments remain unsupported. Nested outbound paths and edits or +deletes in `inbox/` and `sent/` are rejected. Unsupported-operation JSON shape: diff --git a/docs/daemon.md b/docs/daemon.md index e0d43111..4b4719c3 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -340,12 +340,15 @@ Before a virtual Gmail direct-send item is pushed, its source file lives under `outbox/` and its temporary File Provider identifier is stored in the durable push journal. After the send and read-back reconcile, the daemon removes that exact user-visible `outbox/` File Provider item on a background thread and then signals -the source `outbox/` and destination `sent/` enumerators. The extension accepts -deletion of a temporary `local:` item that reconciliation has already removed -from daemon state, while remote and unconfirmed deletes remain blocked. Unsent -Gmail draft pushes remain under the `draft/` path and reconcile as Gmail drafts, -not direct sends. Versioned sync anchors expire older anchor formats without -trying to infer locally-created deletions from an incomplete directory snapshot. +the source `outbox/` and destination `sent/` enumerators. Moving an existing +remote draft from `draft/` to `outbox/` updates that Gmail draft from local +Markdown, sends it, retires the draft entity, and reconciles the sent message +under `sent/`. The extension accepts deletion of a temporary `local:` item that +reconciliation has already removed from daemon state, while remote and +unconfirmed deletes remain blocked. Unsent Gmail draft pushes remain under the +`draft/` path and reconcile as Gmail drafts, not direct sends. Versioned sync +anchors expire older anchor formats without trying to infer locally-created +deletions from an incomplete directory snapshot. Scheduled reconciliation skips writing placeholder Markdown files for virtual filesystem projection modes such as `macos_file_provider` and `linux_fuse`; it diff --git a/docs/gmail-connector.md b/docs/gmail-connector.md index 5b5e37d0..43bded5f 100644 --- a/docs/gmail-connector.md +++ b/docs/gmail-connector.md @@ -19,10 +19,11 @@ The connector projects a fixed mailbox shape: outbox/ ``` -`inbox/` and `sent/` are read-only message folders. `draft/` contains unsent -Gmail drafts and is the local write surface for creating another unsent Gmail UI -draft. `outbox/` is the reviewed direct-send surface: a Markdown file created -directly under `outbox/` is sent through Gmail when pushed. +`inbox/` and `sent/` are read-only message folders. `draft/` contains remote +Gmail drafts and local draft creates. Editing a remote draft and pushing updates +the Gmail draft. `outbox/` is local-only send staging for reviewed direct sends. +A Markdown file created directly under `outbox/` is sent through Gmail when +pushed. ## OAuth @@ -70,11 +71,13 @@ CLI overrides: ## Projection And Pull By default, Pull enumerates the recent 100 inbox messages, recent 100 sent -messages, and recent 100 Gmail drafts. The `draft/` folder contains unsent -Gmail drafts: drafts created in Gmail are pulled there, and a local Markdown -file pushed from `draft/` becomes another unsent Gmail draft. The `outbox/` folder -is local-only outbound staging for reviewed direct sends; pushing a direct child -under `outbox/` sends the message and reconciles the result under `sent/`. +messages, and recent 100 Gmail drafts. The `draft/` folder contains remote +Gmail drafts and local draft creates: drafts created in Gmail are pulled there, +editing a remote draft and pushing updates the Gmail draft, and a local Markdown +file pushed from `draft/` becomes another unsent Gmail draft. The `outbox/` +folder is local-only outbound staging for reviewed direct sends; pushing a +direct child under `outbox/` sends the message and reconciles the result under +`sent/`. Gmail mounts can be registered with a date window: @@ -122,8 +125,9 @@ gmail-main/ ``` Inbox, sent, and thread content is read-only. Creating a Markdown file directly -under `draft/` creates an unsent Gmail draft when pushed. Creating a Markdown -file directly under `outbox/` sends the message when pushed. +under `draft/` creates an unsent Gmail draft when pushed. Editing a remote draft +under `draft/` and pushing updates that Gmail draft. Creating a Markdown file +directly under `outbox/` sends the message when pushed. ## Attachments @@ -137,9 +141,9 @@ thread and writes them under: ``` Rendered message frontmatter includes attachment filename, MIME type, size, -Gmail attachment ID, and the local path. Draft creation and direct send still -reject `attachment` or `attachments` frontmatter; outbound attachments require a -separate design. +Gmail attachment ID, and the local path. Gmail outbound attachments remain +unsupported; draft updates, draft creation, and direct send reject `attachment` +or `attachments` frontmatter. ## Write Policy @@ -163,6 +167,13 @@ Creating a Markdown file directly under `outbox/` is a reviewed direct send: outbox/reply.md ``` +Moving an existing remote draft directly from `draft/` to `outbox/` sends that +draft after applying local edits: + +```text +draft/follow-up.md -> outbox/follow-up.md +``` + Nested outbound paths are rejected for both draft creation and direct send: ```text @@ -185,19 +196,21 @@ subject: Follow up Thanks for the notes. I will follow up here. ``` -`loc push` for a Gmail file under `draft/` creates an unsent Gmail draft. Send -that draft later from the Gmail UI after review. `loc push` for a Gmail file -under `outbox/` directly sends the message after Locality review and push -approval. Attachments are not supported for Gmail outbound mail in v1; -`attachment` or `attachments` frontmatter is rejected for both paths. +`loc push` for a new Gmail file under `draft/` creates an unsent Gmail draft. +`loc push` for an existing remote draft under `draft/` updates the Gmail draft. +Move an existing remote draft from `draft/` to `outbox/` and push to send the +updated draft. `loc push` for a new Gmail file under `outbox/` directly sends +the message after Locality review and push approval. Gmail outbound attachments +are not supported in v1; `attachment` or `attachments` frontmatter is rejected +for both paths. On macOS File Provider mounts, the push journal remembers the temporary local `outbox/` item identifier before sending. Once Gmail apply and read-back both succeed, Locality removes that exact File Provider item and signals both the `outbox/` and `sent/` containers. Remote or unconfirmed item deletion remains -blocked. Pushing from `draft/` remains unsent Gmail draft creation and signals -the `draft/` container. This does not require the user to run `loc pull` or -refresh Finder. +blocked. Pushing from `draft/` creates or updates an unsent Gmail draft and +signals the `draft/` container. This does not require the user to run `loc pull` +or refresh Finder. ## Live E2E @@ -205,8 +218,9 @@ refresh Finder. mount/pull/diff/push, daemon, and Linux FUSE projection. It creates an unsent Gmail draft through the mounted `draft/` folder, verifies the draft projection, and deletes the Gmail draft through Gmail API cleanup. Direct-send behavior uses -the same outbound document shape under `outbox/` and is covered by the outbox-folder -push and File Provider reconciliation tests. +the same outbound document shape under `outbox/`; remote draft move-to-send +behavior is covered by the outbox-folder push and File Provider reconciliation +tests. Use a stored `connection:gmail-live` credential and a recipient address: diff --git a/docs/superpowers/plans/2026-08-04-gmail-remote-draft-edit-send.md b/docs/superpowers/plans/2026-08-04-gmail-remote-draft-edit-send.md new file mode 100644 index 00000000..cb58eb56 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-gmail-remote-draft-edit-send.md @@ -0,0 +1,787 @@ +# Gmail Remote Draft Edit And Send Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` for task dispatch, or `superpowers:executing-plans` for inline execution. Execute this plan task-by-task and update each checkbox as it completes. + +**Goal:** Gmail drafts projected under `draft/` can be viewed from the remote account, edited locally, pushed back as Gmail drafts, or moved to `outbox/` and pushed to send the updated draft. + +**Architecture:** Treat Gmail drafts as stable Gmail draft resources, not as message-only projections. Draft entities use Locality remote IDs of the form `gmail-draft:`, render both `gmail.draft_id` and the current contained `gmail.message_id`, and route draft updates through `users.drafts.update`. Moving a remote draft file from `draft/` to `outbox/` updates that draft with the local state, sends it with `users.drafts.send`, reconciles the sent message under `sent/`, and retires the old draft entity. + +**Tech Stack:** Rust workspace, `locality-gmail`, `localityd`, `locality-core`, Gmail REST draft resource, existing Locality push journal, projection, virtual mutation, and reconcile pipeline. + +--- + +## Current Baseline + +- `draft/` already exists and is populated by listing Gmail messages with the `DRAFT` label. +- Existing draft entries currently use the contained Gmail message ID as the entity remote ID. +- Creating a new Markdown file under `draft/` creates a Gmail draft. +- Creating a new Markdown file under `outbox/` sends a new Gmail message. +- `GmailApi` already has `create_draft`, `send_message`, and `send_draft`, but it does not expose `list_drafts`, `get_draft_full`, or `update_draft`. +- `GmailConnector::supported_push_operations()` currently returns only `CreateEntity`. +- `source_move_decision_for_parent_path()` currently rejects all Gmail moves. +- The generic push reconciler expects `MoveEntity` to move the same remote ID to the destination parent. Draft-to-outbox send does not fit that shape because the operation consumes a draft and creates a sent message. + +## Target User Semantics + +- `draft/` shows remote Gmail drafts. +- Editing a direct child Markdown file in `draft/` and pushing updates the Gmail draft. +- Creating a new direct child Markdown file in `draft/` creates a new unsent Gmail draft. +- Creating a new direct child Markdown file in `outbox/` sends a new message immediately on push. +- Moving a remote draft file directly from `draft/` to `outbox/` and pushing sends the existing Gmail draft after first updating it from the local Markdown state. +- `outbox/` remains local-only staging. It does not enumerate remote children. +- Attachments in outbound Gmail documents remain unsupported and must be rejected before push. +- Inbox and sent messages remain read-only. + +## Files To Change + +- `crates/locality-gmail/src/dto.rs` +- `crates/locality-gmail/src/client.rs` +- `crates/locality-gmail/src/render.rs` +- `crates/locality-gmail/src/connector.rs` +- `crates/localityd/src/source.rs` +- `crates/localityd/src/gmail.rs` +- `crates/localityd/src/push.rs` +- `crates/localityd/tests/source_descriptor.rs` +- `crates/localityd/tests/push_preparation.rs` +- `crates/localityd/tests/push_execution.rs` +- `tests/live_gmail_vfs_roundtrip.sh` +- `docs/gmail-connector.md` +- `docs/cli.md` +- `docs/daemon.md` +- `docs/agent-guidance.md` +- `apps/desktop/src-tauri/src/agent_guidance.rs` + +## Implementation Steps + +### Task 1: Add Gmail Draft API DTOs And Client Methods + +- [ ] Add failing unit coverage for draft API request paths in `crates/locality-gmail/src/client.rs`. + - Add a test server case for `GET /users/me/drafts?maxResults=100`. + - Add a test server case for `GET /users/me/drafts/?format=full`. + - Add a test server case for `PUT /users/me/drafts/`. + - Keep the existing create draft and send tests passing. + +- [ ] Add DTOs to `crates/locality-gmail/src/dto.rs`: + +```rust +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GmailDraftList { + #[serde(default)] + pub drafts: Vec, + pub next_page_token: Option, + pub result_size_estimate: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GmailDraftRef { + pub id: String, + pub message: GmailMessage, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GmailDraftUpdateRequest { + pub message: GmailRawMessage, +} +``` + +- [ ] Extend `GmailApi` in `crates/locality-gmail/src/client.rs`: + +```rust +fn list_drafts( + &self, + max_results: u32, + page_token: Option<&str>, + query: Option<&str>, +) -> LocalityResult; +fn get_draft_full(&self, draft_id: &str) -> LocalityResult; +fn update_draft( + &self, + draft_id: &str, + request: GmailDraftUpdateRequest, +) -> LocalityResult; +``` + +- [ ] Add `put_json_with_context` beside `post_json_with_context`: + +```rust +fn put_json_with_context(&self, path: &str, body: &B, context: &str) -> LocalityResult +where + T: DeserializeOwned, + B: Serialize + ?Sized, +{ + decode_response( + self.client + .put(format!("{}{}", self.base_url, path)) + .bearer_auth(&self.access_token) + .json(body) + .send(), + context, + ) +} +``` + +- [ ] Implement the new HTTP methods: + +```rust +fn list_drafts( + &self, + max_results: u32, + page_token: Option<&str>, + search_query: Option<&str>, +) -> LocalityResult { + let mut params = vec![("maxResults".to_string(), max_results.to_string())]; + if let Some(page_token) = page_token { + params.push(("pageToken".to_string(), page_token.to_string())); + } + if let Some(search_query) = search_query { + params.push(("q".to_string(), search_query.to_string())); + } + self.get_json("/users/me/drafts", params) +} + +fn get_draft_full(&self, draft_id: &str) -> LocalityResult { + let draft_id = percent_encode_path_segment(draft_id); + self.get_json( + &format!("/users/me/drafts/{draft_id}"), + vec![("format".to_string(), "full".to_string())], + ) +} + +fn update_draft( + &self, + draft_id: &str, + request: GmailDraftUpdateRequest, +) -> LocalityResult { + let draft_id = percent_encode_path_segment(draft_id); + self.put_json_with_context( + &format!("/users/me/drafts/{draft_id}"), + &request, + "gmail draft update", + ) +} +``` + +- [ ] Update all fake `GmailApi` implementations in tests to implement the new trait methods. + +- [ ] Run: + +```bash +cargo test -p locality-gmail client +``` + +Expected result: + +```text +test result: ok +``` + +### Task 2: Project Remote Drafts With Stable Draft IDs + +- [ ] Add remote ID helpers in `crates/locality-gmail/src/connector.rs`: + +```rust +const DRAFT_REMOTE_PREFIX: &str = "gmail-draft:"; + +fn draft_remote_id(draft_id: &str) -> RemoteId { + RemoteId::new(format!("{DRAFT_REMOTE_PREFIX}{draft_id}")) +} + +fn parse_draft_remote_id(remote_id: &RemoteId) -> Option<&str> { + remote_id.as_str().strip_prefix(DRAFT_REMOTE_PREFIX) +} +``` + +- [ ] Extend `GmailNativeBundle` in `crates/locality-gmail/src/render.rs`: + +```rust +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GmailNativeBundle { + pub mailbox: String, + pub draft_id: Option, + pub message: GmailMessage, +} +``` + +- [ ] Update existing `GmailNativeBundle` construction sites to pass `draft_id: None` for inbox and sent messages. + +- [ ] Render `gmail.draft_id` for drafts only: + +```rust +if let Some(draft_id) = &bundle.draft_id { + gmail.insert( + Value::String("draft_id".to_string()), + Value::String(draft_id.clone()), + ); +} +``` + +- [ ] Add `list_draft_entries` in `crates/locality-gmail/src/connector.rs` that uses `GmailApi::list_drafts` instead of `list_messages("DRAFT")`: + +```rust +fn list_draft_entries( + api: &dyn GmailApi, + settings: &GmailMountSettings, + mount_id: &MountId, + parent_path: &Path, +) -> LocalityResult> { + let mut entries = Vec::new(); + let mut page_token: Option = None; + loop { + let list = api.list_drafts( + GMAIL_PAGE_SIZE, + page_token.as_deref(), + gmail_recent_query(settings), + )?; + for draft in list.drafts { + entries.push(draft_entry(mount_id, parent_path, draft.id, draft.message)?); + } + match list.next_page_token { + Some(next) => page_token = Some(next), + None => break, + } + } + Ok(entries) +} +``` + +- [ ] Add `draft_entry` beside `message_entry`: + +```rust +fn draft_entry( + mount_id: &MountId, + parent_path: &Path, + draft_id: String, + message: GmailMessage, +) -> LocalityResult { + let version = remote_version(&message); + let name = message_filename(&message, "draft"); + Ok(TreeEntry::page( + mount_id.clone(), + draft_remote_id(&draft_id), + parent_path.join(name), + version, + )) +} +``` + +- [ ] Replace only the `DRAFT_FOLDER_ID` enumeration paths to call `list_draft_entries`. + - `INBOX` and `SENT` continue using `list_label_entries`. + - Thread view still lists inbox and sent as threads, but `draft/` uses draft resources. + +- [ ] Update `observe()`: + - If `parse_draft_remote_id(remote_id)` returns a draft ID, call `get_draft_full(draft_id)`. + - Build the path from `draft_entry`. + - Return a `RemoteObservation` for the stable `gmail-draft:` remote ID. + +- [ ] Update `fetch()`: + - If `parse_draft_remote_id(remote_id)` returns a draft ID, call `get_draft_full(draft_id)`. + - Return a native entity with `GmailNativeBundle { mailbox: "draft".to_string(), draft_id: Some(draft.id), message: draft.message }`. + +- [ ] Keep backward compatibility for old state whose entity remote ID is a Gmail message ID with label `DRAFT`. + - In `observe()` and `fetch()`, if the remote ID is not prefixed with `gmail-draft:`, keep the existing message-based path. + - Old projected draft entries may be refreshed into the new draft ID on the next full enumerate or pull. + +- [ ] Update tests in `crates/locality-gmail/src/connector.rs`: + - `enumerate_projects_four_folders_and_recent_inbox_sent_draft_messages` should assert draft entries have remote IDs like `gmail-draft:draft-1`. + - `list_children_for_draft_folder_returns_remote_drafts` should assert `list_drafts` was called and message listing for `DRAFT` was not called. + - Add `fetch_remote_draft_uses_draft_resource_and_renders_draft_id`. + - Add `observe_remote_draft_uses_draft_resource`. + - Add `fetch_legacy_draft_message_remote_id_still_works`. + +- [ ] Run: + +```bash +cargo test -p locality-gmail connector::tests::enumerate_projects_four_folders_and_recent_inbox_sent_draft_messages +cargo test -p locality-gmail connector::tests::list_children_for_draft_folder_returns_remote_drafts +cargo test -p locality-gmail connector::tests::fetch_remote_draft_uses_draft_resource_and_renders_draft_id +cargo test -p locality-gmail connector::tests::observe_remote_draft_uses_draft_resource +``` + +Expected result for each command: + +```text +test result: ok +``` + +### Task 3: Allow And Validate Draft Updates And Draft-To-Outbox Moves + +- [ ] Update `GmailConnector::capabilities()` in `crates/locality-gmail/src/connector.rs`: + +```rust +supports_entity_body_updates: true, +``` + +- [ ] Update `GmailConnector::supported_push_operations()`: + +```rust +[ + PushOperationKind::CreateEntity, + PushOperationKind::UpdateProperties, + PushOperationKind::UpdateEntityBody, + PushOperationKind::MoveEntity, +] +.into_iter() +.collect() +``` + +- [ ] Update `source_move_decision_for_parent_path()` in `crates/localityd/src/source.rs` so Gmail only allows moves into the direct `outbox/` folder: + +```rust +"gmail" => { + if relative_path.components().count() == 1 && relative_path == Path::new("outbox") { + SourceWriteDecision::writable() + } else { + SourceWriteDecision::read_only( + "Gmail only supports moving an existing draft directly into outbox/ to send it", + ) + } +} +``` + +- [ ] Keep direct writes under `draft/` and `outbox/` allowed. Do not allow nested outbound files. + +- [ ] Add a shared outbound validator in `crates/localityd/src/gmail.rs`: + +```rust +fn validate_gmail_outbound_document( + document: &CanonicalDocument, + issues: &mut Vec, +) { + let gmail = document + .frontmatter + .get("gmail") + .and_then(|value| value.as_mapping()); + let attachments = gmail + .and_then(|gmail| gmail.get(Value::String("attachments".to_string()))) + .and_then(|value| value.as_sequence()) + .map(|items| !items.is_empty()) + .unwrap_or(false); + if attachments { + issues.push(ValidationIssue::error( + "gmail_attachments_not_supported", + "Gmail outbound messages with attachments are not supported yet", + )); + } + if document + .frontmatter + .get("subject") + .and_then(|value| value.as_str()) + .map(|subject| subject.trim().is_empty()) + .unwrap_or(true) + { + issues.push(ValidationIssue::error( + "gmail_subject_required", + "Gmail outbound messages require a subject", + )); + } + let has_recipient = document + .frontmatter + .get("to") + .and_then(|value| value.as_sequence()) + .map(|items| { + items.iter().any(|item| { + item.as_str() + .map(|recipient| !recipient.trim().is_empty()) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + if !has_recipient { + issues.push(ValidationIssue::error( + "gmail_recipient_required", + "Gmail outbound messages require at least one recipient in `to`", + )); + } +} +``` + +- [ ] Call `validate_gmail_outbound_document` from both create and changed validation paths for direct children of `draft/` and `outbox/`. + +- [ ] Keep `inbox/` and `sent/` changed items blocked. + +- [ ] Add tests in `crates/localityd/tests/source_descriptor.rs`: + - `gmail_move_policy_allows_draft_to_direct_outbox_parent` + - `gmail_move_policy_rejects_nested_outbox_parent` + - `local_gmail_validator_allows_valid_changed_draft` + - `local_gmail_validator_rejects_changed_draft_without_to` + - `local_gmail_validator_rejects_changed_draft_without_subject` + - `local_gmail_validator_rejects_changed_draft_with_attachments` + - `local_gmail_validator_allows_valid_changed_outbox` + - `local_gmail_validator_blocks_changed_inbox_and_sent_items` remains green. + +- [ ] Run: + +```bash +cargo test -p localityd --test source_descriptor gmail_move_policy +cargo test -p localityd --test source_descriptor local_gmail_validator +``` + +Expected result: + +```text +test result: ok +``` + +### Task 4: Prepare Push Plans For Draft Update And Draft Send + +- [ ] Add a push preparation test for editing an existing remote draft in `crates/localityd/tests/push_preparation.rs`. + - Mount a Gmail draft entity with remote ID `gmail-draft:draft-1`. + - Hydrate it under `draft/Original.md`. + - Edit subject, `to`, and body. + - Prepare push for that file. + - Assert the plan contains `UpdateProperties` and `UpdateEntityBody`. + - Assert the plan does not contain `CreateEntity`. + - Assert guardrails are not dangerous. + +- [ ] Add a push preparation test for moving a draft into outbox with edits. + - Mount a Gmail draft entity with remote ID `gmail-draft:draft-1`. + - Hydrate it under `draft/Original.md`. + - Move it to `outbox/Send Now.md` through virtual mutation setup. + - Edit body content in the moved file. + - Prepare push for `outbox/Send Now.md`. + - Assert operation order: + +```text +MoveEntity +UpdateProperties +UpdateEntityBody +``` + +- [ ] Assert the `MoveEntity` has: + - `entity_id == RemoteId::new("gmail-draft:draft-1")` + - `new_parent_id == RemoteId::new("gmail-folder:outbox")` + - `projected_path == "outbox/Send Now.md"` + +- [ ] If the preparation test produces only `MoveEntity` without content updates, inspect `lower_move_document_operations()` in `crates/localityd/src/push.rs` before changing it. The expected behavior is already used by generic move tests and should be preserved. + +- [ ] Run: + +```bash +cargo test -p localityd --test push_preparation gmail_draft +``` + +Expected result: + +```text +test result: ok +``` + +### Task 5: Apply Draft Update And Draft-To-Outbox Send In The Gmail Connector + +- [ ] Add connector tests in `crates/locality-gmail/src/connector.rs` before implementation: + - `apply_updates_remote_gmail_draft` + - `apply_sends_remote_gmail_draft_moved_to_outbox` + - `apply_rejects_move_of_non_draft_gmail_entity` + - `apply_rejects_gmail_draft_move_to_non_outbox_parent` + - `apply_rejects_draft_update_with_attachments` + +- [ ] Extend the fake Gmail API in connector tests: + - Store draft list responses. + - Store full draft responses by draft ID. + - Record `updated_drafts: Vec<(String, String)>`. + - Record `sent_drafts: Vec`. + - Return updated drafts with a changed contained message ID to prove identity remains `gmail-draft:`. + +- [ ] Add a connector-side mutation accumulator: + +```rust +#[derive(Clone, Debug, Default)] +struct DraftApplyMutation { + draft_remote_id: RemoteId, + draft_id: String, + projected_path: Option, + move_to_outbox: bool, + title: Option, + properties: BTreeMap, + body: Option, + operation_index: usize, + operation_id: Option, +} +``` + +- [ ] In `apply()`, scan `request.plan.operations` once: + - Keep existing `CreateEntity` behavior for new files under `draft/` and `outbox/`. + - Collect `UpdateProperties` and `UpdateEntityBody` for remote IDs where `parse_draft_remote_id(entity_id)` succeeds. + - Collect `MoveEntity` only when the entity ID is a draft remote ID and the new parent ID is `OUTBOX_FOLDER_ID`. + - Reject `MoveEntity` for non-draft Gmail entities. + - Reject draft moves to any parent other than `OUTBOX_FOLDER_ID`. + +- [ ] For each collected draft mutation: + - Load the current draft through `api.get_draft_full(draft_id)`. + - Render the current draft into a `GmailDraftDocument`. + - Apply `UpdateProperties` values over the rendered document. + - Apply `UpdateEntityBody` over the rendered document body. + - If `MoveEntity.new_title` exists and no explicit subject property exists, use the move title as the subject fallback. + - Parse and validate the resulting outbound document with the same rules used for create. + - Build MIME with no new Locality-generated `Message-ID` for remote draft updates. + +- [ ] Add helper: + +```rust +fn update_gmail_draft_from_document( + api: &dyn GmailApi, + draft_id: &str, + draft: &GmailDraftDocument, +) -> LocalityResult { + let raw = raw_message_base64url(&build_draft_mime_with_message_id(draft, None)?); + api.update_draft( + draft_id, + crate::dto::GmailDraftUpdateRequest { + message: GmailRawMessage { raw }, + }, + ) +} +``` + +- [ ] For normal draft updates: + - Call `update_gmail_draft_from_document`. + - Add `draft_remote_id(draft_id)` to `changed_remote_ids`. + - Do not create, archive, or move any entity. + +- [ ] For draft-to-outbox sends: + - Call `update_gmail_draft_from_document`. + - Call `api.send_draft(GmailDraftSendRequest { id: draft_id.to_string() })`. + - Add the sent message ID to `changed_remote_ids`. + - Return both effects: + +```rust +JournalApplyEffect::ArchivedEntity { + operation_id, + operation_index, + entity_id: draft_remote_id(draft_id), +} +JournalApplyEffect::CreatedEntity { + operation_id, + operation_index, + parent_id: RemoteId::new(SENT_FOLDER_ID), + entity_id: RemoteId::new(sent.id), +} +``` + +- [ ] Guard idempotency for draft sends. + - Extend `block_ambiguous_gmail_send_replay` in `crates/localityd/src/push.rs` to also block pending Gmail `MoveEntity` operations whose destination parent is `gmail-folder:outbox`. + - In connector apply, if `send_draft` fails after `update_draft` succeeds, return an error that leaves the journal unresolved and forces review instead of retrying silently. + +- [ ] Run: + +```bash +cargo test -p locality-gmail connector::tests::apply_updates_remote_gmail_draft +cargo test -p locality-gmail connector::tests::apply_sends_remote_gmail_draft_moved_to_outbox +cargo test -p locality-gmail connector::tests::apply_rejects_move_of_non_draft_gmail_entity +cargo test -p locality-gmail connector::tests::apply_rejects_gmail_draft_move_to_non_outbox_parent +``` + +Expected result for each command: + +```text +test result: ok +``` + +### Task 6: Reconcile Draft Update And Draft Send Correctly In The Daemon + +- [ ] Add a daemon push execution test in `crates/localityd/tests/push_execution.rs` for draft update: + - Create a mounted Gmail draft entity under `draft/Remote Draft.md`. + - Edit recipient, subject, and body. + - Execute push. + - Assert fake Gmail API recorded `update_draft("draft-1", raw)`. + - Assert no send happened. + - Assert the projected draft remains under `draft/`. + - Assert the entity remote ID remains `gmail-draft:draft-1`. + - Assert shadow body and frontmatter match the updated local file. + +- [ ] Add a daemon push execution test for moving a draft to outbox: + - Create a mounted Gmail draft entity under `draft/Remote Draft.md`. + - Move it to `outbox/Send Remote Draft.md` through the visible projection or virtual mutation helper used by adjacent tests. + - Edit the moved file body before push. + - Execute push. + - Assert fake Gmail API recorded one `update_draft("draft-1", raw)` before one `send_draft("draft-1")`. + - Assert the final projected file is under `sent/`. + - Assert no file remains under `draft/Remote Draft.md`. + - Assert no file remains under `outbox/Send Remote Draft.md`. + - Assert the final sent entity remote ID is the sent message ID returned by fake Gmail. + +- [ ] Add a Gmail-specific reconcile predicate in `crates/localityd/src/push.rs`: + +```rust +fn is_gmail_draft_send_move(operation: &PushOperation) -> bool { + match operation { + PushOperation::MoveEntity { + entity_id, + new_parent_id, + .. + } => { + entity_id.as_str().starts_with("gmail-draft:") + && new_parent_id.as_str() == "gmail-folder:outbox" + } + _ => false, + } +} +``` + +- [ ] In the generic `MoveEntity` reconcile branch: + - If `is_gmail_draft_send_move(operation)` is true and the apply effects contain a `CreatedEntity` for the same operation index with parent `gmail-folder:sent`, skip the same-entity move invariant. + - Require an `ArchivedEntity` effect for the moved draft remote ID. + - Remove or mark archived the old draft entity through the same local cleanup path used by other archive effects. + +- [ ] Extend the `CreatedEntity` reconcile branch: + - Accept a matching original operation of either `CreateEntity` or Gmail draft-send `MoveEntity`. + - For Gmail draft-send `MoveEntity`, use the `CreatedEntity` effect to fetch and render the sent message. + - Save the sent entity under `sent/` using `created_entity_reconcile_path_from_rendered`. + - Clear the virtual move mutation after the sent entity is saved. + +- [ ] Keep existing non-Gmail move reconciliation unchanged. + +- [ ] Run: + +```bash +cargo test -p localityd --test push_execution daemon_push_reconciles_gmail_draft_update +cargo test -p localityd --test push_execution daemon_push_reconciles_gmail_draft_move_to_outbox_send +cargo test -p localityd --test push_execution daemon_push_reconciles_gmail_draft_create_to_draft_folder +cargo test -p localityd --test push_execution daemon_push_reconciles_gmail_send_create_to_sent_folder +``` + +Expected result for each command: + +```text +test result: ok +``` + +### Task 7: Update User And Agent Guidance + +- [ ] Update `docs/gmail-connector.md`: + - Explain that `draft/` contains remote Gmail drafts and local draft creates. + - Explain that editing a remote draft and pushing updates the Gmail draft. + - Explain that moving a remote draft from `draft/` to `outbox/` and pushing sends the updated draft. + - Keep `outbox/` described as local-only send staging. + - State that outbound attachments remain unsupported. + +- [ ] Update `docs/agent-guidance.md`: + - Tell agents to leave messages in `draft/` when the user asks to draft or revise. + - Tell agents to use `outbox/` only when the user explicitly asks to send now. + - Tell agents that moving an existing draft into `outbox/` sends that draft after applying local edits. + - Tell agents to inspect with `loc status` and `loc diff` before pushing if Live Mode is paused, conflicted, or review-needed. + +- [ ] Update `docs/cli.md` and `docs/daemon.md` where Gmail write flows are described. + +- [ ] Update `apps/desktop/src-tauri/src/agent_guidance.rs` with the same draft versus outbox distinction. + +- [ ] Add exact-output assertions in any tests that snapshot generated guidance. If no snapshot test exists, add or update the closest source descriptor guidance test in `crates/localityd/tests/source_descriptor.rs`. + +- [ ] Run: + +```bash +cargo test -p localityd --test source_descriptor gmail +``` + +Expected result: + +```text +test result: ok +``` + +### Task 8: Extend Live Gmail Roundtrip Coverage + +- [ ] Update `tests/live_gmail_vfs_roundtrip.sh` with a gated remote draft edit/send scenario. + - Use the existing OAuth and Gmail helpers already in the script. + - Create a draft through the Gmail drafts API or through Locality `draft/`. + - Pull or wait until the draft appears under local `draft/`. + - Edit subject and body in the local Markdown file. + - Push the draft file. + - Verify through Gmail drafts API that the draft content changed. + - Move the local draft file to `outbox/`. + - Push the moved outbox file. + - Verify through Gmail API that a sent message exists with the updated subject and body. + - Clean up scratch draft or sent artifacts when the Gmail API allows safe cleanup. + +- [ ] Keep the live test opt-in through its existing environment requirements. Do not make live Gmail API tests part of default CI unless the repo already does that. + +- [ ] Run the live test only when credentials are available: + +```bash +tests/live_gmail_vfs_roundtrip.sh +``` + +Expected result: + +```text +PASS live Gmail VFS roundtrip +``` + +### Task 9: Full Verification + +- [ ] Format the workspace: + +```bash +cargo fmt --all --check +``` + +Expected result: + +```text +command exits 0 with no output +``` + +- [ ] Run focused Gmail connector tests: + +```bash +cargo test -p locality-gmail +``` + +Expected result: + +```text +test result: ok +``` + +- [ ] Run focused daemon tests: + +```bash +cargo test -p localityd --test source_descriptor +cargo test -p localityd --test push_preparation +cargo test -p localityd --test push_execution +``` + +Expected result for each command: + +```text +test result: ok +``` + +- [ ] Run the full workspace test set if time permits: + +```bash +cargo test --workspace +``` + +Expected result: + +```text +test result: ok +``` + +- [ ] Inspect changed files: + +```bash +git diff --stat +git diff -- crates/locality-gmail/src crates/localityd/src docs apps tests +``` + +Expected result: + +```text +Only Gmail draft edit/send code, tests, live test coverage, and documentation changed. +``` + +## Design Checks + +- [ ] Remote draft identity is the Gmail draft ID, not the contained message ID. +- [ ] Rendered draft frontmatter includes `gmail.draft_id` and current `gmail.message_id`. +- [ ] Updating a draft preserves Locality entity identity even if Gmail returns a new contained message ID. +- [ ] Moving a draft to `outbox/` sends the existing draft and reconciles the resulting sent message under `sent/`. +- [ ] `outbox/` remains empty on remote enumeration. +- [ ] Inbox and sent remain read-only. +- [ ] Attachments remain rejected for outbound Gmail push. +- [ ] Generic non-Gmail move reconciliation remains unchanged. +- [ ] Agent guidance clearly distinguishes `draft/` from `outbox/`. diff --git a/platform/linux/locality-fuse/src/linux.rs b/platform/linux/locality-fuse/src/linux.rs index c97fec5f..40cf0704 100644 --- a/platform/linux/locality-fuse/src/linux.rs +++ b/platform/linux/locality-fuse/src/linux.rs @@ -812,7 +812,17 @@ where } self.remove_cached_path(&path); } else { - return Ok(item); + match self.client.item(&item.identifier) { + Ok(report) => { + self.cache_item_at(path.clone(), report.item.clone()); + return Ok(report.item); + } + Err(error) if error.is_remote_missing() => { + self.remove_cached_path(&path); + return Err(FuseError::NotFound); + } + Err(error) => return Err(error), + } } } let parent = path.parent().unwrap_or_else(|| Path::new(ROOT_PATH)); @@ -1810,7 +1820,7 @@ mod tests { state_root: std::env::temp_dir(), mount_id: "slack-main".to_string(), root: root.clone(), - children: BTreeMap::new(), + children: fake_children(&root, vec![item.clone()]), created_files: Mutex::new(Vec::new()), created_item: None, renamed: Mutex::new(Vec::new()), @@ -1847,6 +1857,43 @@ mod tests { assert_eq!(&read.data[..], b"recent"); } + #[test] + fn resolve_path_evicts_cached_child_missing_from_daemon_state() { + let root = test_root_item(); + let stale = test_named_item("gmail-draft:stale", "stale.md", VirtualFsItemKind::File); + let current = test_named_item("gmail-draft:current", "current.md", VirtualFsItemKind::File); + let fs = AgentFuse { + client: FakeClient { + state_root: std::env::temp_dir(), + mount_id: "gmail-main".to_string(), + root: root.clone(), + children: BTreeMap::from([(root.identifier.clone(), vec![current])]), + created_files: Mutex::new(Vec::new()), + created_item: None, + renamed: Mutex::new(Vec::new()), + trashed: Mutex::new(Vec::new()), + }, + cache: Mutex::new(BTreeMap::from([ + (PathBuf::from(ROOT_PATH), root), + (PathBuf::from("/stale.md"), stale), + ])), + handles: Mutex::new(BTreeMap::new()), + next_handle: AtomicU64::new(1), + }; + + let error = fs + .resolve_path(Path::new("/stale.md")) + .expect_err("stale cached child should miss"); + + assert!(matches!(error, FuseError::NotFound)); + assert!( + !fs.cache + .lock() + .expect("fuse item cache") + .contains_key(Path::new("/stale.md")) + ); + } + #[test] fn read_only_parent_rejects_create_file_before_daemon_create() { let mut root = test_root_item(); @@ -1927,7 +1974,7 @@ mod tests { state_root: std::env::temp_dir(), mount_id: "notion-main".to_string(), root: root.clone(), - children: BTreeMap::new(), + children: fake_children(&root, vec![source.clone(), read_only_parent.clone()]), created_files: Mutex::new(Vec::new()), created_item: None, renamed: Mutex::new(Vec::new()), @@ -1967,7 +2014,7 @@ mod tests { state_root: std::env::temp_dir(), mount_id: "notion-main".to_string(), root: root.clone(), - children: BTreeMap::new(), + children: fake_children(&root, vec![item.clone()]), created_files: Mutex::new(Vec::new()), created_item: None, renamed: Mutex::new(Vec::new()), @@ -2022,7 +2069,7 @@ mod tests { state_root: std::env::temp_dir(), mount_id: "notion-main".to_string(), root: shared_test_root_item(), - children: BTreeMap::new(), + children: fake_children(&shared_test_root_item(), vec![mount_item.clone()]), created_files: Mutex::new(Vec::new()), created_item: None, renamed: Mutex::new(Vec::new()), @@ -2320,28 +2367,17 @@ mod tests { let parent = test_named_item("children:page-root", "Page", VirtualFsItemKind::Folder); let stale_dir = test_named_item("children:local:draft", "Draft", VirtualFsItemKind::Folder); let stale_page = test_named_item("local:draft", "page.md", VirtualFsItemKind::File); + let remote_dir = test_named_item("children:page-draft", "Draft", VirtualFsItemKind::Folder); + let remote_page = test_named_item("page-draft", "page.md", VirtualFsItemKind::File); let fs = AgentFuse { client: FakeClient { state_root: std::env::temp_dir(), mount_id: "notion-main".to_string(), root: root.clone(), children: BTreeMap::from([ - ( - "children:page-root".to_string(), - vec![test_named_item( - "children:page-draft", - "Draft", - VirtualFsItemKind::Folder, - )], - ), - ( - "children:page-draft".to_string(), - vec![test_named_item( - "page-draft", - "page.md", - VirtualFsItemKind::File, - )], - ), + (root.identifier.clone(), vec![parent.clone()]), + ("children:page-root".to_string(), vec![remote_dir.clone()]), + ("children:page-draft".to_string(), vec![remote_page]), ]), created_files: Mutex::new(Vec::new()), created_item: None, @@ -2421,7 +2457,7 @@ mod tests { state_root: std::env::temp_dir(), mount_id: "notion-main".to_string(), root: root.clone(), - children: BTreeMap::new(), + children: fake_children(&root, vec![page_dir.clone()]), created_files: Mutex::new(Vec::new()), created_item: None, renamed: Mutex::new(Vec::new()), @@ -2502,7 +2538,7 @@ mod tests { state_root: std::env::temp_dir(), mount_id: "notion-main".to_string(), root: root.clone(), - children: BTreeMap::new(), + children: fake_children(&root, vec![page_dir.clone()]), created_files: Mutex::new(Vec::new()), created_item: None, renamed: Mutex::new(Vec::new()), @@ -2588,6 +2624,13 @@ mod tests { } } + fn fake_children( + parent: &VirtualFsItem, + children: Vec, + ) -> BTreeMap> { + BTreeMap::from([(parent.identifier.clone(), children)]) + } + fn test_root_item() -> VirtualFsItem { VirtualFsItem { identifier: "mount:notion-main".to_string(), @@ -2649,13 +2692,30 @@ mod tests { } fn item(&self, identifier: &str) -> Result { - if identifier != projection_root_identifier(&self.mount_id) { - return Err(FuseError::Daemon(format!("missing item {identifier}"))); + if let Some(item) = self + .children + .values() + .flat_map(|children| children.iter()) + .find(|item| item.identifier == identifier) + { + return Ok(VirtualFsItemReport { + mount_id: self.mount_id.clone(), + item: item.clone(), + }); } - Ok(VirtualFsItemReport { - mount_id: self.mount_id.clone(), - item: self.root.clone(), - }) + if identifier == projection_root_identifier(&self.mount_id) { + return Ok(VirtualFsItemReport { + mount_id: self.mount_id.clone(), + item: self.root.clone(), + }); + } + if identifier == DIRECTORY_METADATA_IDENTIFIER { + return Ok(VirtualFsItemReport { + mount_id: self.mount_id.clone(), + item: directory_metadata_item(), + }); + } + Err(FuseError::NotFound) } fn children( diff --git a/tests/live_connector_common.sh b/tests/live_connector_common.sh index e314e8a1..f25d7d71 100755 --- a/tests/live_connector_common.sh +++ b/tests/live_connector_common.sh @@ -1051,6 +1051,11 @@ SQL } build_live_binaries() { + local default_loc_bin="$live_connector_repo_root/target/debug/loc" + local default_localityd_bin="$live_connector_repo_root/target/debug/localityd" + local default_fuse_bin="$live_connector_repo_root/target/debug/locality-fuse" + local should_build=0 + if [[ $# -eq 0 ]]; then loc_bin="$(_live_loc_bin)" localityd_bin="$(_live_localityd_bin)" @@ -1063,9 +1068,19 @@ build_live_binaries() { live_fail "build_live_binaries requires no arguments or loc, localityd, and FUSE binary paths" return 1 fi + + if [[ "$loc_bin" == "$default_loc_bin" \ + && "$localityd_bin" == "$default_localityd_bin" \ + && "$fuse_bin" == "$default_fuse_bin" ]]; then + should_build=1 + fi if [[ ! -x "$loc_bin" || ! -x "$localityd_bin" || ! -x "$fuse_bin" ]]; then + should_build=1 + fi + if [[ "$should_build" == "1" ]]; then (cd "$live_connector_repo_root" && cargo build -p loc-cli -p localityd -p locality-fuse) fi + loc_bin="$(_live_loc_bin)" localityd_bin="$(_live_localityd_bin)" fuse_bin="$(_live_fuse_bin)" diff --git a/tests/live_gmail_vfs_roundtrip.sh b/tests/live_gmail_vfs_roundtrip.sh index 0253fc94..aebcc647 100755 --- a/tests/live_gmail_vfs_roundtrip.sh +++ b/tests/live_gmail_vfs_roundtrip.sh @@ -1,23 +1,29 @@ #!/usr/bin/env bash set -euo pipefail -if [[ "${LOCALITY_LIVE_GMAIL_VFS:-}" != "1" ]]; then - echo "skip: set LOCALITY_LIVE_GMAIL_VFS=1 to run the live Gmail VFS test" - exit 0 -fi - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/live_connector_common.sh source "$script_dir/live_connector_common.sh" -require_linux_fuse -require_live_env \ - LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON \ - LOCALITY_GMAIL_LIVE_TO_EMAIL +if [[ "${LOCALITY_LIVE_GMAIL_SELFTEST:-}" != "1" ]]; then + if [[ "${LOCALITY_LIVE_GMAIL_VFS:-}" != "1" ]]; then + echo "skip: set LOCALITY_LIVE_GMAIL_VFS=1 to run the live Gmail VFS test" + exit 0 + fi + + require_linux_fuse + require_live_env \ + LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON \ + LOCALITY_GMAIL_LIVE_TO_EMAIL -if ! command -v curl >/dev/null 2>&1; then - live_fail "curl is not installed" + if ! command -v curl >/dev/null 2>&1; then + live_fail "curl is not installed" + fi +fi + +if ! command -v python3 >/dev/null 2>&1; then + live_fail "python3 is not installed" fi loc_bin="${LOCALITY_BIN:-./target/debug/loc}" @@ -46,6 +52,17 @@ send_status_report="$tmp_root/send-status.json" send_diff_report="$tmp_root/send-diff.json" send_push_report="$tmp_root/send-push.json" send_pull_after_push_report="$tmp_root/send-pull-after-push.json" +remote_draft_diff_report="$tmp_root/remote-draft-diff.json" +remote_draft_push_report="$tmp_root/remote-draft-push.json" +remote_draft_get_report="$tmp_root/remote-draft.json" +remote_draft_send_diff_report="$tmp_root/remote-draft-send-diff.json" +remote_draft_send_push_report="$tmp_root/remote-draft-send-push.json" +remote_sent_list_report="$tmp_root/remote-sent-list.json" +remote_sent_get_report="$tmp_root/remote-sent-message.json" +stale_draft_push_report="$tmp_root/stale-draft-push.json" +stale_draft_pull_report="$tmp_root/stale-draft-pull.json" +stale_draft_send_report="$tmp_root/stale-draft-send.json" +stale_draft_prune_pull_report="$tmp_root/stale-draft-prune-pull.json" drafts_list_report="$tmp_root/gmail-drafts.json" draft_get_report="$tmp_root/gmail-draft.json" credential_path="" @@ -57,6 +74,7 @@ raw_message_id="" draft_id="" draft_deleted=0 draft_cleanup_needed=0 +remote_sent_message_id="" subject="" marker="" step="initializing" @@ -102,15 +120,17 @@ wait_for_outbound_dirs() { projected_gmail_draft_matches_message() { local path="$1" - local searched_message_id="$2" + local searched_message_id="${2:-}" + local searched_draft_id="${3:-}" - python3 - "$path" "$searched_message_id" <<'PY' + python3 - "$path" "$searched_message_id" "$searched_draft_id" <<'PY' import pathlib import re import sys path = pathlib.Path(sys.argv[1]) searched_message_id = sys.argv[2] +searched_draft_id = sys.argv[3] try: text = path.read_text(encoding="utf-8") except OSError: @@ -146,7 +166,7 @@ def block_key(line): has_gmail_connector = any(key_value(line, "connector") == "gmail" for line in frontmatter) has_draft_mailbox = False -has_matching_message_id = False +has_matching_identity = False active_block = None active_indent = -1 @@ -168,20 +188,27 @@ for line in frontmatter: if active_block == "gmail": if key_value(line, "mailbox") == "draft": has_draft_mailbox = True - if key_value(line, "message_id") == searched_message_id: - has_matching_message_id = True - elif active_block == "loc" and key_value(line, "id") == searched_message_id: - has_matching_message_id = True - -if has_gmail_connector and has_draft_mailbox and has_matching_message_id: + if searched_draft_id and key_value(line, "draft_id") == searched_draft_id: + has_matching_identity = True + if searched_message_id and key_value(line, "message_id") == searched_message_id: + has_matching_identity = True + elif active_block == "loc": + loc_id = key_value(line, "id") + if searched_draft_id and loc_id == f"gmail-draft:{searched_draft_id}": + has_matching_identity = True + if searched_message_id and loc_id == searched_message_id: + has_matching_identity = True + +if has_gmail_connector and has_draft_mailbox and has_matching_identity: raise SystemExit(0) raise SystemExit(1) PY } -wait_for_marker_under_draft() { +find_marker_under_draft() { local marker="$1" - local searched_message_id="$2" + local searched_message_id="${2:-}" + local searched_draft_id="${3:-}" local draft_dir="$mount_root/draft" local attempts="${LOCALITY_GMAIL_LIVE_MARKER_WAIT_ATTEMPTS:-120}" local attempt @@ -191,7 +218,8 @@ wait_for_marker_under_draft() { if [[ -d "$draft_dir" ]]; then while IFS= read -r match_path; do [[ -z "$match_path" ]] && continue - if projected_gmail_draft_matches_message "$match_path" "$searched_message_id"; then + if projected_gmail_draft_matches_message "$match_path" "$searched_message_id" "$searched_draft_id"; then + printf '%s\n' "$match_path" return 0 fi done < <(grep -R -F -l -- "$marker" "$draft_dir" 2>/dev/null || true) @@ -201,6 +229,25 @@ wait_for_marker_under_draft() { live_fail "created Gmail draft marker was not visible under draft/ after pull" } +wait_for_marker_under_draft() { + find_marker_under_draft "$@" >/dev/null +} + +wait_for_path_absent() { + local path="$1" + local label="$2" + local attempts="${LOCALITY_GMAIL_LIVE_MARKER_WAIT_ATTEMPTS:-120}" + local attempt + + for ((attempt = 1; attempt <= attempts; attempt++)); do + if [[ ! -e "$path" ]]; then + return 0 + fi + sleep 0.25 + done + live_fail "$label remained visible at $path" +} + wait_for_marker_under_sent() { local marker="$1" local sent_dir="$mount_root/sent" @@ -281,44 +328,309 @@ if token: PY } -gmail_draft_subject_matches() { - local draft_json_path="$1" +gmail_message_subject_body_matches() { + local message_json_path="$1" local expected_subject="$2" + local expected_body_marker="$3" + local wrapper="${4:-message}" - python3 - "$draft_json_path" "$expected_subject" <<'PY' + python3 - "$message_json_path" "$expected_subject" "$expected_body_marker" "$wrapper" <<'PY' +import base64 import json import pathlib import sys path = pathlib.Path(sys.argv[1]) expected_subject = sys.argv[2] +expected_body_marker = sys.argv[3] +wrapper = sys.argv[4] + try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: raise SystemExit(1) -message = data.get("message") + +message = data.get("message") if wrapper == "draft" else data if not isinstance(message, dict): raise SystemExit(1) payload = message.get("payload") if not isinstance(payload, dict): raise SystemExit(1) + +subject = None for header in payload.get("headers") or []: - if not isinstance(header, dict): - continue - if header.get("name", "").lower() == "subject" and header.get("value") == expected_subject: - raise SystemExit(0) + if isinstance(header, dict) and header.get("name", "").lower() == "subject": + subject = header.get("value") + break +if subject != expected_subject: + raise SystemExit(1) + +def decode_body(data): + if not isinstance(data, str) or not data: + return "" + padding = "=" * (-len(data) % 4) + try: + return base64.urlsafe_b64decode((data + padding).encode("ascii")).decode("utf-8", "replace") + except Exception: + return "" + +def collect_text_parts(part): + if not isinstance(part, dict): + return [] + texts = [] + body = part.get("body") + mime_type = part.get("mimeType") + if isinstance(body, dict) and body.get("data") and (mime_type in (None, "text/plain") or not part.get("parts")): + texts.append(decode_body(body.get("data"))) + for child in part.get("parts") or []: + texts.extend(collect_text_parts(child)) + return texts + +body_text = "\n".join(collect_text_parts(payload)) +if expected_body_marker in body_text: + raise SystemExit(0) raise SystemExit(1) PY } -delete_created_gmail_draft() { +gmail_draft_subject_body_matches() { + local draft_json_path="$1" + local expected_subject="$2" + local expected_body_marker="$3" + + gmail_message_subject_body_matches "$draft_json_path" "$expected_subject" "$expected_body_marker" draft +} + +rewrite_gmail_markdown_subject_body() { + local markdown_path="$1" + local updated_subject="$2" + local updated_body="$3" + + python3 - "$markdown_path" "$updated_subject" "$updated_body" <<'PY' +import pathlib +import sys + +path = pathlib.Path(sys.argv[1]) +updated_subject = sys.argv[2] +updated_body = sys.argv[3] +text = path.read_text(encoding="utf-8") +lines = text.splitlines() +if not lines or lines[0].strip() != "---": + raise SystemExit("missing frontmatter") + +closing_index = None +for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + closing_index = index + break +if closing_index is None: + raise SystemExit("unterminated frontmatter") + +def yaml_scalar(value): + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + +frontmatter = lines[:closing_index] +subject_line = "subject: " + yaml_scalar(updated_subject) +for index, line in enumerate(frontmatter): + if line.startswith("subject:"): + frontmatter[index] = subject_line + break +else: + frontmatter.append(subject_line) + +path.write_text("\n".join(frontmatter + ["---", updated_body, ""]), encoding="utf-8") +PY +} + +classify_created_gmail_remote_id() { + local created_remote_id="$1" + + draft_id="" + raw_message_id="" + if [[ -z "$created_remote_id" ]]; then + return 1 + fi + if [[ "$created_remote_id" == gmail-draft:* ]]; then + draft_id="${created_remote_id#gmail-draft:}" + elif [[ "$created_remote_id" == gmail-message:* ]]; then + raw_message_id="${created_remote_id#gmail-message:}" + else + raw_message_id="$created_remote_id" + fi + [[ -n "$draft_id" || -n "$raw_message_id" ]] +} + +gmail_access_token() { + local mode="${1:-required}" + local access_token + + if [[ -z "$credential_path" ]]; then + credential_path="$(credential_file_path "$state_root" "connection:$connection_id")" + fi + + access_token="$(credential_access_token "$credential_path" 2>/dev/null || true)" + if [[ -z "$access_token" ]]; then + if [[ "$mode" == "required" ]]; then + live_fail "could not read Gmail OAuth access token" + fi + return 1 + fi + printf '%s\n' "$access_token" +} + +get_gmail_draft_full() { + local searched_draft_id="$1" + local output_path="$2" + local access_token + + access_token="$(gmail_access_token required)" + if curl -fsS --get "https://gmail.googleapis.com/gmail/v1/users/me/drafts/$searched_draft_id" \ + -H "Authorization: Bearer $access_token" \ + --data-urlencode "format=full" \ + >"$output_path" 2>>"$command_log"; then + unset access_token + return 0 + fi + unset access_token + return 1 +} + +wait_for_gmail_draft_content() { + local searched_draft_id="$1" + local expected_subject="$2" + local expected_body_marker="$3" + local attempts="${LOCALITY_GMAIL_LIVE_API_WAIT_ATTEMPTS:-120}" + local attempt + + for ((attempt = 1; attempt <= attempts; attempt++)); do + if get_gmail_draft_full "$searched_draft_id" "$remote_draft_get_report" \ + && gmail_message_subject_body_matches "$remote_draft_get_report" "$expected_subject" "$expected_body_marker" draft; then + return 0 + fi + sleep 0.25 + done + live_fail "updated Gmail draft content was not visible through the drafts API" +} + +wait_for_gmail_draft_absent() { + local searched_draft_id="$1" + local attempts="${LOCALITY_GMAIL_LIVE_API_WAIT_ATTEMPTS:-120}" + local attempt + + for ((attempt = 1; attempt <= attempts; attempt++)); do + if ! get_gmail_draft_full "$searched_draft_id" "$draft_get_report"; then + return 0 + fi + sleep 0.25 + done + live_fail "sent Gmail draft was still visible through the drafts API" +} + +send_gmail_draft() { + local searched_draft_id="$1" + local output_path="$2" + local request_body="$tmp_root/gmail-draft-send-body.json" + local access_token + + access_token="$(gmail_access_token required)" + python3 - "$searched_draft_id" >"$request_body" <<'PY' +import json +import sys + +print(json.dumps({"id": sys.argv[1]})) +PY + if curl -fsS -X POST "https://gmail.googleapis.com/gmail/v1/users/me/drafts/send" \ + -H "Authorization: Bearer $access_token" \ + -H "Content-Type: application/json" \ + --data-binary "@$request_body" \ + >"$output_path" 2>>"$command_log"; then + unset access_token + return 0 + fi + unset access_token + return 1 +} + +find_gmail_sent_message_id_for_subject_body() { + local expected_subject="$1" + local expected_body_marker="$2" + local access_token + local message_id + local attempts="${LOCALITY_GMAIL_LIVE_API_WAIT_ATTEMPTS:-120}" + local attempt + + access_token="$(gmail_access_token required)" + for ((attempt = 1; attempt <= attempts; attempt++)); do + if curl -fsS --get "https://gmail.googleapis.com/gmail/v1/users/me/messages" \ + -H "Authorization: Bearer $access_token" \ + --data-urlencode "maxResults=10" \ + --data-urlencode "q=in:sent subject:\"$expected_subject\"" \ + >"$remote_sent_list_report" 2>>"$command_log"; then + while IFS= read -r message_id; do + [[ -z "$message_id" ]] && continue + if curl -fsS --get "https://gmail.googleapis.com/gmail/v1/users/me/messages/$message_id" \ + -H "Authorization: Bearer $access_token" \ + --data-urlencode "format=full" \ + >"$remote_sent_get_report" 2>>"$command_log" \ + && gmail_message_subject_body_matches "$remote_sent_get_report" "$expected_subject" "$expected_body_marker" message; then + printf '%s\n' "$message_id" + unset access_token + return 0 + fi + done < <(python3 - "$remote_sent_list_report" <<'PY' +import json +import pathlib +import sys + +try: + data = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +except Exception: + raise SystemExit(0) +for message in data.get("messages") or []: + if isinstance(message, dict) and message.get("id"): + print(message["id"]) +PY +) + fi + sleep 0.25 + done + unset access_token + return 1 +} + +trash_gmail_message() { + local searched_message_id="$1" + local mode="${2:-best_effort}" + local access_token + + [[ -z "$searched_message_id" ]] && return 0 + + access_token="$(gmail_access_token "$mode" 2>/dev/null || true)" + if [[ -z "$access_token" ]]; then + return 1 + fi + + if curl -fsS -X POST "https://gmail.googleapis.com/gmail/v1/users/me/messages/$searched_message_id/trash" \ + -H "Authorization: Bearer $access_token" >/dev/null 2>>"$command_log"; then + unset access_token + return 0 + fi + + unset access_token + if [[ "$mode" == "required" ]]; then + live_fail "failed to trash sent Gmail scratch message" + fi + return 1 +} + +resolve_created_gmail_draft_id() { local mode="${1:-best_effort}" local access_token - if [[ "$draft_deleted" == "1" ]]; then + if [[ -n "$draft_id" ]]; then return 0 fi - if [[ -z "$draft_id" && -z "$raw_message_id" && "$draft_cleanup_needed" != "1" ]]; then + if [[ -z "$raw_message_id" && "$draft_cleanup_needed" != "1" ]]; then return 0 fi if [[ -z "$credential_path" ]]; then @@ -333,58 +645,83 @@ delete_created_gmail_draft() { return 1 fi - if [[ -z "$draft_id" ]]; then - local page_token="" - local candidate_draft_id - while :; do - local curl_args=( - -fsS - --get - "https://gmail.googleapis.com/gmail/v1/users/me/drafts" - -H - "Authorization: Bearer $access_token" - --data-urlencode - "maxResults=100" - ) - if [[ -n "$page_token" ]]; then - curl_args+=(--data-urlencode "pageToken=$page_token") + local page_token="" + local candidate_draft_id + while :; do + local curl_args=( + -fsS + --get + "https://gmail.googleapis.com/gmail/v1/users/me/drafts" + -H + "Authorization: Bearer $access_token" + --data-urlencode + "maxResults=100" + ) + if [[ -n "$page_token" ]]; then + curl_args+=(--data-urlencode "pageToken=$page_token") + fi + if ! curl "${curl_args[@]}" >"$drafts_list_report" 2>>"$command_log"; then + unset access_token + if [[ "$mode" == "required" ]]; then + live_fail "failed to list Gmail drafts" fi - if ! curl "${curl_args[@]}" >"$drafts_list_report" 2>>"$command_log"; then - unset access_token - if [[ "$mode" == "required" ]]; then - live_fail "failed to list Gmail drafts during cleanup" + return 1 + fi + + if [[ -n "$raw_message_id" ]]; then + draft_id="$(find_gmail_draft_id "$drafts_list_report" "$raw_message_id" 2>/dev/null || true)" + fi + if [[ -z "$draft_id" && -n "${subject:-}" && -n "${marker:-}" ]]; then + while IFS= read -r candidate_draft_id; do + [[ -z "$candidate_draft_id" ]] && continue + if curl -fsS --get "https://gmail.googleapis.com/gmail/v1/users/me/drafts/$candidate_draft_id" \ + -H "Authorization: Bearer $access_token" \ + --data-urlencode "format=full" \ + >"$draft_get_report" 2>>"$command_log" \ + && gmail_draft_subject_body_matches "$draft_get_report" "$subject" "$marker"; then + draft_id="$candidate_draft_id" + break fi - return 1 - fi + done < <(gmail_draft_ids "$drafts_list_report" 2>/dev/null || true) + fi + if [[ -n "$draft_id" ]]; then + break + fi + page_token="$(gmail_drafts_next_page_token "$drafts_list_report" 2>/dev/null || true)" + if [[ -z "$page_token" ]]; then + break + fi + done - if [[ -n "$raw_message_id" ]]; then - draft_id="$(find_gmail_draft_id "$drafts_list_report" "$raw_message_id" 2>/dev/null || true)" - fi - if [[ -z "$draft_id" && -n "${subject:-}" ]]; then - while IFS= read -r candidate_draft_id; do - [[ -z "$candidate_draft_id" ]] && continue - if curl -fsS --get "https://gmail.googleapis.com/gmail/v1/users/me/drafts/$candidate_draft_id" \ - -H "Authorization: Bearer $access_token" \ - --data-urlencode "format=metadata" \ - --data-urlencode "metadataHeaders=Subject" \ - >"$draft_get_report" 2>>"$command_log" \ - && gmail_draft_subject_matches "$draft_get_report" "$subject"; then - draft_id="$candidate_draft_id" - break - fi - done < <(gmail_draft_ids "$drafts_list_report" 2>/dev/null || true) - fi - if [[ -n "$draft_id" ]]; then - break - fi - page_token="$(gmail_drafts_next_page_token "$drafts_list_report" 2>/dev/null || true)" - if [[ -z "$page_token" ]]; then - break - fi - done + unset access_token + if [[ -z "$draft_id" ]]; then + if [[ "$mode" == "required" ]]; then + live_fail "could not find Gmail draft id for created message" + fi + return 1 + fi + return 0 +} + +delete_created_gmail_draft() { + local mode="${1:-best_effort}" + local access_token + + if [[ "$draft_deleted" == "1" ]]; then + return 0 + fi + if [[ -z "$draft_id" && -z "$raw_message_id" && "$draft_cleanup_needed" != "1" ]]; then + return 0 + fi + if [[ -z "$credential_path" ]]; then + credential_path="$(credential_file_path "$state_root" "connection:$connection_id")" + fi + if [[ -z "$draft_id" ]]; then + if ! resolve_created_gmail_draft_id "$mode"; then + return 1 + fi if [[ -z "$draft_id" ]]; then - unset access_token if [[ "$mode" == "required" ]]; then live_fail "could not find Gmail draft id for created message during cleanup" fi @@ -392,6 +729,14 @@ delete_created_gmail_draft() { fi fi + access_token="$(credential_access_token "$credential_path" 2>/dev/null || true)" + if [[ -z "$access_token" ]]; then + if [[ "$mode" == "required" ]]; then + live_fail "could not read Gmail OAuth access token for cleanup" + fi + return 1 + fi + if curl -fsS -X DELETE "https://gmail.googleapis.com/gmail/v1/users/me/drafts/$draft_id" \ -H "Authorization: Bearer $access_token" >/dev/null 2>>"$command_log"; then draft_deleted=1 @@ -432,6 +777,10 @@ cleanup() { delete_created_gmail_draft best_effort >/dev/null 2>&1 || \ echo "warning: failed to delete created Gmail draft during cleanup" >&2 fi + if [[ -n "${remote_sent_message_id:-}" ]]; then + trash_gmail_message "$remote_sent_message_id" best_effort >/dev/null 2>&1 || \ + echo "warning: failed to trash sent Gmail scratch message during cleanup" >&2 + fi stop_live_processes "$locality_root" "$fuse_pid" "$daemon_pid" unset LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON if [[ "${LOCALITY_GMAIL_LIVE_KEEP_TMP:-}" == "1" ]]; then @@ -441,6 +790,89 @@ cleanup() { fi } +run_gmail_helper_selftest() { + local selftest_subject="Locality Gmail helper self-test subject" + local selftest_marker="Locality Gmail helper self-test body marker" + local encoded_marker + local draft_json="$tmp_root/selftest-draft.json" + local message_json="$tmp_root/selftest-message.json" + + encoded_marker="$(python3 - "$selftest_marker" <<'PY' +import base64 +import sys + +print(base64.urlsafe_b64encode(sys.argv[1].encode("utf-8")).decode("ascii").rstrip("=")) +PY +)" + + cat >"$draft_json" <"$message_json" <"$push_report" 2>>"$command_log" assert_json_ok "$push_report" "Gmail push report" message_id="$(json_field "$push_report" "changed_remote_ids.0" 2>/dev/null || true)" -if [[ -z "$message_id" ]]; then - live_fail "Gmail push report did not include changed_remote_ids.0" -fi -if [[ "$message_id" == gmail-message:* ]]; then - raw_message_id="${message_id#gmail-message:}" -else - raw_message_id="$message_id" -fi -if [[ -z "$raw_message_id" ]]; then +if ! classify_created_gmail_remote_id "$message_id"; then live_fail "Gmail push report produced an empty message id" fi @@ -548,7 +972,7 @@ LOCALITY_STATE_DIR="$state_root" "$loc_bin" pull --json "$mount_root" \ assert_json_ok "$pull_after_push_report" "Gmail pull-after-push report" step="verifying created Gmail draft marker under draft" -wait_for_marker_under_draft "$marker" "$raw_message_id" +wait_for_marker_under_draft "$marker" "$raw_message_id" "$draft_id" step="deleting created Gmail draft" delete_created_gmail_draft required @@ -588,8 +1012,166 @@ if [[ "${LOCALITY_LIVE_GMAIL_SEND:-0}" == "1" ]]; then step="verifying Gmail direct send marker under sent" wait_for_marker_under_sent "$send_marker" - echo "live Gmail API, CLI, daemon, and Linux FUSE draft and direct-send checks passed" + stale_subject="Locality live Gmail stale remote draft $unique" + stale_marker="Locality live Gmail stale remote draft marker $unique" + stale_draft_path="$mount_root/draft/locality-live-gmail-stale-draft-$unique.md" + + step="creating Gmail stale remote draft prune fixture through Linux FUSE" + draft_deleted=0 + draft_cleanup_needed=1 + subject="$stale_subject" + marker="$stale_marker" + printf -- '---\nto:\n - "%s"\nsubject: "%s"\n---\n%s\n' \ + "$LOCALITY_GMAIL_LIVE_TO_EMAIL" \ + "$stale_subject" \ + "$stale_marker" >"$stale_draft_path" + + step="pushing Gmail stale remote draft prune fixture" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" push --json -y "$stale_draft_path" \ + >"$stale_draft_push_report" 2>>"$command_log" + assert_json_ok "$stale_draft_push_report" "Gmail stale remote draft create push report" + stale_created_id="$(json_field "$stale_draft_push_report" "changed_remote_ids.0" 2>/dev/null || true)" + if ! classify_created_gmail_remote_id "$stale_created_id"; then + live_fail "Gmail stale remote draft create push report produced an empty message id" + fi + if [[ -z "$draft_id" ]]; then + resolve_created_gmail_draft_id required + fi + stale_draft_id="$draft_id" + + step="pulling Gmail draft directory after stale remote draft create" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" pull --json "$mount_root/draft" \ + >"$stale_draft_pull_report" 2>>"$command_log" + assert_json_ok "$stale_draft_pull_report" "Gmail stale remote draft pull report" + + step="finding projected Gmail stale remote draft" + stale_projected_draft_path="$(find_marker_under_draft "$stale_marker" "$raw_message_id" "$stale_draft_id")" + if [[ -z "$stale_projected_draft_path" ]]; then + live_fail "created Gmail stale remote draft file was not visible under draft/" + fi + + step="sending Gmail stale remote draft outside the local draft folder" + if ! send_gmail_draft "$stale_draft_id" "$stale_draft_send_report"; then + live_fail "failed to send Gmail stale remote draft through drafts API" + fi + stale_sent_message_id="$(json_field "$stale_draft_send_report" "id" 2>/dev/null || true)" + if [[ -n "$stale_sent_message_id" ]]; then + remote_sent_message_id="$stale_sent_message_id" + fi + draft_deleted=1 + draft_cleanup_needed=0 + draft_id="" + raw_message_id="" + + step="waiting for Gmail stale remote draft to leave drafts API" + wait_for_gmail_draft_absent "$stale_draft_id" + + step="pulling Gmail draft directory after stale remote draft was sent" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" pull --json "$mount_root/draft" \ + >"$stale_draft_prune_pull_report" 2>>"$command_log" + assert_json_ok "$stale_draft_prune_pull_report" "Gmail stale remote draft prune pull report" + + step="verifying sent Gmail stale remote draft was pruned from draft" + wait_for_path_absent "$stale_projected_draft_path" "sent Gmail stale remote draft" + if grep -R -F -q -- "$stale_marker" "$mount_root/draft" 2>/dev/null; then + live_fail "sent Gmail stale remote draft marker remained visible under draft/" + fi + + step="trashing sent Gmail stale remote draft scratch message" + trash_gmail_message "$remote_sent_message_id" best_effort >/dev/null 2>&1 || \ + echo "warning: Gmail OAuth scope did not allow trashing sent stale-draft scratch message" >&2 + remote_sent_message_id="" + + remote_subject="Locality live Gmail remote draft $unique" + remote_marker="Locality live Gmail remote draft marker $unique" + remote_updated_subject="Locality live Gmail remote draft updated $unique" + remote_updated_marker="Locality live Gmail remote draft updated marker $unique" + remote_draft_path="$mount_root/draft/locality-live-gmail-remote-draft-$unique.md" + remote_outbox_path="$mount_root/outbox/locality-live-gmail-remote-draft-$unique.md" + + step="creating Gmail remote draft edit/send draft through Linux FUSE" + draft_deleted=0 + draft_cleanup_needed=1 + subject="$remote_subject" + marker="$remote_marker" + printf -- '---\nto:\n - "%s"\nsubject: "%s"\n---\n%s\n' \ + "$LOCALITY_GMAIL_LIVE_TO_EMAIL" \ + "$remote_subject" \ + "$remote_marker" >"$remote_draft_path" + + step="pushing Gmail remote draft edit/send draft" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" push --json -y "$remote_draft_path" \ + >"$remote_draft_push_report" 2>>"$command_log" + assert_json_ok "$remote_draft_push_report" "Gmail remote draft create push report" + remote_created_id="$(json_field "$remote_draft_push_report" "changed_remote_ids.0" 2>/dev/null || true)" + if ! classify_created_gmail_remote_id "$remote_created_id"; then + live_fail "Gmail remote draft create push report produced an empty message id" + fi + + step="pulling Gmail workspace after remote draft create" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" pull --json "$mount_root" \ + >"$pull_after_push_report" 2>>"$command_log" + assert_json_ok "$pull_after_push_report" "Gmail pull-after-remote-draft-create report" + + if [[ -z "$draft_id" ]]; then + resolve_created_gmail_draft_id required + fi + + step="finding projected Gmail remote draft" + remote_projected_draft_path="$(find_marker_under_draft "$remote_marker" "$raw_message_id" "$draft_id")" + if [[ -z "$remote_projected_draft_path" ]]; then + live_fail "created Gmail remote draft file was not visible under draft/" + fi + + step="editing projected Gmail remote draft" + rewrite_gmail_markdown_subject_body "$remote_projected_draft_path" "$remote_updated_subject" "$remote_updated_marker" + + step="diffing edited Gmail remote draft" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" diff --json "$remote_projected_draft_path" \ + >"$remote_draft_diff_report" 2>>"$command_log" + assert_json_ok "$remote_draft_diff_report" "Gmail remote draft edit diff report" + assert_json_field_equals "$remote_draft_diff_report" "action" "confirm_plan" "Gmail remote draft edit diff report" + + step="pushing edited Gmail remote draft" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" push --json -y "$remote_projected_draft_path" \ + >"$remote_draft_push_report" 2>>"$command_log" + assert_json_ok "$remote_draft_push_report" "Gmail remote draft edit push report" + + step="verifying edited Gmail remote draft through drafts API" + wait_for_gmail_draft_content "$draft_id" "$remote_updated_subject" "$remote_updated_marker" + + step="moving edited Gmail remote draft to outbox" + mv "$remote_projected_draft_path" "$remote_outbox_path" + + step="diffing Gmail remote draft send move" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" diff --json "$remote_outbox_path" \ + >"$remote_draft_send_diff_report" 2>>"$command_log" + assert_json_ok "$remote_draft_send_diff_report" "Gmail remote draft send diff report" + assert_json_field_equals "$remote_draft_send_diff_report" "action" "confirm_plan" "Gmail remote draft send diff report" + + step="pushing Gmail remote draft send move" + LOCALITY_STATE_DIR="$state_root" "$loc_bin" push --json -y "$remote_outbox_path" \ + >"$remote_draft_send_push_report" 2>>"$command_log" + assert_json_ok "$remote_draft_send_push_report" "Gmail remote draft send push report" + draft_deleted=1 + draft_cleanup_needed=0 + draft_id="" + raw_message_id="" + + step="verifying Gmail remote draft sent message through Gmail API" + verified_remote_sent_id="$(find_gmail_sent_message_id_for_subject_body "$remote_updated_subject" "$remote_updated_marker" || true)" + if [[ -z "$verified_remote_sent_id" ]]; then + live_fail "sent Gmail remote draft message with updated content was not visible through Gmail API" + fi + remote_sent_message_id="$verified_remote_sent_id" + + step="trashing sent Gmail remote draft scratch message" + trash_gmail_message "$remote_sent_message_id" best_effort >/dev/null 2>&1 || \ + echo "warning: Gmail OAuth scope did not allow trashing sent scratch message" >&2 + remote_sent_message_id="" + + echo "live Gmail API, CLI, daemon, and Linux FUSE draft, direct-send, stale draft prune, and remote draft edit/send checks passed" else - echo "skip: set LOCALITY_LIVE_GMAIL_SEND=1 to run the live Gmail direct-send check; this sends a real email" + echo "skip: set LOCALITY_LIVE_GMAIL_SEND=1 to run the live Gmail direct-send and remote draft edit/send checks; this sends real email" echo "live Gmail API, CLI, daemon, and Linux FUSE draft checks passed" fi