From 62922969defbd7a58300ab457170f3ddf3ed8949 Mon Sep 17 00:00:00 2001 From: Harsh Gupta Date: Wed, 22 Jul 2026 21:42:19 -0700 Subject: [PATCH 1/5] Create unsent Gmail drafts on push --- crates/locality-gmail/src/connector.rs | 67 +++++++++--------------- crates/localityd/src/gmail.rs | 7 ++- crates/localityd/src/push.rs | 2 +- crates/localityd/src/source.rs | 2 +- crates/localityd/tests/push_execution.rs | 31 ++++------- docs/cli.md | 6 +-- docs/gmail-connector.md | 15 +++--- 7 files changed, 52 insertions(+), 78 deletions(-) diff --git a/crates/locality-gmail/src/connector.rs b/crates/locality-gmail/src/connector.rs index d16793f8..3db9205b 100644 --- a/crates/locality-gmail/src/connector.rs +++ b/crates/locality-gmail/src/connector.rs @@ -22,10 +22,7 @@ use locality_core::{LocalityError, LocalityResult}; use serde::{Deserialize, Serialize}; use crate::client::{GmailApi, HttpGmailApiClient}; -use crate::dto::{ - GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailRawMessage, GmailThread, - header_map, -}; +use crate::dto::{GmailDraftCreateRequest, GmailMessage, GmailRawMessage, GmailThread, header_map}; use crate::oauth::GMAIL_CONNECTOR_ID; use crate::render::{ GmailDraftDocument, GmailNativeBundle, GmailThreadMessageNativeBundle, GmailThreadNativeBundle, @@ -485,30 +482,13 @@ impl Connector for GmailConnector { raw: raw_message_base64url(&mime), }, })?; - let sent = match self - .api - .send_draft(GmailDraftSendRequest { id: created.id }) - { - Ok(sent) => sent, - Err(error) => { - match find_sent_message_by_message_id(self.api.as_ref(), &message_id) { - Ok(Some(sent)) => sent, - Ok(None) => return Err(error), - Err(lookup_error) => { - return Err(LocalityError::Io(format!( - "gmail draft send ambiguous after send failure; sent lookup failed: {lookup_error}" - ))); - } - } - } - }; - let sent_id = RemoteId::new(sent.id); - changed_remote_ids.push(sent_id.clone()); + 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(SENT_FOLDER_ID), - entity_id: sent_id, + parent_id: RemoteId::new(DRAFT_FOLDER_ID), + entity_id: draft_message_id, }); } @@ -1815,7 +1795,7 @@ mod tests { } #[test] - fn apply_create_entity_creates_and_sends_gmail_draft() { + fn apply_create_entity_creates_unsent_gmail_draft() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); let plan = PushPlan::new( @@ -1851,10 +1831,13 @@ mod tests { }) .expect("apply"); - assert_eq!(result.changed_remote_ids, vec![RemoteId::new("sent-msg-1")]); + assert_eq!( + result.changed_remote_ids, + vec![RemoteId::new("draft-message-1")] + ); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 1); - assert_eq!(calls.sent_drafts, vec!["draft-1"]); + assert!(calls.sent_drafts.is_empty()); let raw = calls.created_draft_raw.last().expect("created draft raw"); let mime = String::from_utf8( URL_SAFE_NO_PAD @@ -1870,7 +1853,7 @@ mod tests { } #[test] - fn apply_create_entity_recovers_existing_sent_message_by_message_id_without_resend() { + fn apply_create_entity_recovers_existing_sent_message_by_message_id_without_duplicate() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); let push_id = PushId("push-1".to_string()); @@ -1927,7 +1910,7 @@ mod tests { } #[test] - fn apply_create_entity_recovers_sent_message_after_send_response_failure() { + fn apply_create_entity_does_not_send_when_send_endpoint_would_fail() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); let push_id = PushId("push-1".to_string()); @@ -1978,22 +1961,19 @@ mod tests { assert_eq!( result.changed_remote_ids, - vec![RemoteId::new("sent-msg-recovered")] + vec![RemoteId::new("draft-message-1")] ); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 1); - assert_eq!(calls.sent_drafts, vec!["draft-1"]); + assert!(calls.sent_drafts.is_empty()); assert_eq!( calls.list_queries, - vec![ - format!("rfc822msgid:<{message_id}>"), - format!("rfc822msgid:<{message_id}>"), - ] + vec![format!("rfc822msgid:<{message_id}>")] ); } #[test] - fn apply_create_entity_preserves_send_ambiguity_when_recovery_lookup_fails() { + fn apply_create_entity_does_not_depend_on_sent_lookup() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); { @@ -2026,7 +2006,7 @@ mod tests { }], ); - let error = connector + let result = connector .apply(locality_connector::ApplyPlanRequest { push_id: &PushId("push-1".to_string()), mount_id: &MountId::new("gmail-main"), @@ -2035,14 +2015,15 @@ mod tests { remote_preconditions: &[] as &[RemotePrecondition], local_root: None, }) - .expect_err("recovery lookup failure should preserve ambiguous send"); + .expect("draft creation should not send or query sent mail"); - let message = error.to_string(); - assert!(message.contains("gmail draft send")); - assert!(message.contains("sent search timed out")); + assert_eq!( + result.changed_remote_ids, + vec![RemoteId::new("draft-message-1")] + ); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 1); - assert_eq!(calls.sent_drafts, vec!["draft-1"]); + assert!(calls.sent_drafts.is_empty()); } #[test] diff --git a/crates/localityd/src/gmail.rs b/crates/localityd/src/gmail.rs index 41dd9cb5..08703710 100644 --- a/crates/localityd/src/gmail.rs +++ b/crates/localityd/src/gmail.rs @@ -343,7 +343,10 @@ pub(crate) fn validate_gmail_changed_frontmatter( context.relative_path, Some(1), "Gmail inbox and sent items are read-only", - Some("create a new Markdown file directly under draft/ to send mail".to_string()), + Some( + "create a new Markdown file directly under draft/ to create an unsent Gmail draft" + .to_string(), + ), )); } Ok(report) @@ -398,7 +401,7 @@ pub(crate) fn validate_gmail_create_frontmatter( "gmail_attachments_unsupported", context.relative_path, Some(1), - "Gmail draft sends do not support attachments", + "Gmail draft creation does not support attachments", Some("remove attachment frontmatter".to_string()), )); } diff --git a/crates/localityd/src/push.rs b/crates/localityd/src/push.rs index 8c974b9b..10cb1466 100644 --- a/crates/localityd/src/push.rs +++ b/crates/localityd/src/push.rs @@ -934,7 +934,7 @@ fn draft_create_auto_save_block_reason(prepared: &PreparedPush) -> Option Some("Gmail draft sends require review".to_string()), + "gmail" => Some("Gmail draft creation requires review".to_string()), "google-calendar" => Some("Google Calendar event creates require review".to_string()), _ => None, } diff --git a/crates/localityd/src/source.rs b/crates/localityd/src/source.rs index 19f5e970..900a9ceb 100644 --- a/crates/localityd/src/source.rs +++ b/crates/localityd/src/source.rs @@ -753,7 +753,7 @@ fn gmail_mount_guidance() -> String { "{}\n\ Gmail facts:\n\ - This mount projects Gmail inbox/, sent/, and draft/ folders.\n\ -- inbox/ and sent/ are read-only. Create a Markdown file directly under draft/ to send mail.\n\ +- inbox/ and sent/ are read-only. Create a Markdown file directly under draft/ to create an unsent Gmail draft.\n\ - Draft creates require `to` frontmatter and either `subject` or `title` frontmatter.\n", generic_mount_guidance("Gmail") ) diff --git a/crates/localityd/tests/push_execution.rs b/crates/localityd/tests/push_execution.rs index beb606d5..649571c6 100644 --- a/crates/localityd/tests/push_execution.rs +++ b/crates/localityd/tests/push_execution.rs @@ -1051,7 +1051,7 @@ fn daemon_push_reconciles_created_database_to_canonical_schema_directory() { } #[test] -fn daemon_push_reconciles_sent_gmail_draft_create_to_sent_folder() { +fn daemon_push_reconciles_gmail_draft_create_to_draft_folder() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/reply.md"); @@ -1066,8 +1066,7 @@ fn daemon_push_reconciles_sent_gmail_draft_create_to_sent_folder() { .expect("cache file"); let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let created_remote_id = RemoteId::new("gmail-message:draft-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -1084,22 +1083,13 @@ fn daemon_push_reconciles_sent_gmail_draft_create_to_sent_folder() { "draft", )) .expect("save draft 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-draft", VirtualMutationKind::Create, None, - Some(draft_folder_id), + Some(draft_folder_id.clone()), "draft/reply.md", Some(cache_path), )) @@ -1107,12 +1097,12 @@ fn daemon_push_reconciles_sent_gmail_draft_create_to_sent_folder() { let source = FakePushSource::default() .with_created_entity( created_remote_id.clone(), - rendered_entity("gmail-message:sent-1", "Body."), + rendered_entity("gmail-message:draft-1", "Body."), ) .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { operation_id: PushOperationId("create-gmail-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id, + parent_id: draft_folder_id, entity_id: created_remote_id.clone(), }]); @@ -1131,12 +1121,11 @@ fn daemon_push_reconciles_sent_gmail_draft_create_to_sent_folder() { assert_eq!(report.action, PushJobAction::Reconciled); let message = store .get_entity(&fixture.mount_id, &created_remote_id) - .expect("get sent message") + .expect("get draft message") .expect("sent message entity"); - assert_eq!(message.path, PathBuf::from("sent/reply.md")); + assert_eq!(message.path, PathBuf::from("draft/reply.md")); assert_eq!(source.requested_paths(), vec![message.path.clone()]); - assert!(content_root.join("sent/reply.md").exists()); - assert!(!content_root.join(source_path).exists()); + assert!(content_root.join(source_path).exists()); assert!( store .find_virtual_mutation_by_path(&fixture.mount_id, source_path) @@ -1650,7 +1639,7 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { ); assert_eq!( report.error.as_ref().expect("error").message, - "Gmail draft sends require review" + "Gmail draft creation requires review" ); let enrollment = store .get_auto_save_enrollment(&fixture.mount_id, source_path) @@ -1659,7 +1648,7 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { assert_eq!(enrollment.state, AutoSaveState::Blocked); assert_eq!( enrollment.last_reason.as_deref(), - Some("Gmail draft sends require review") + Some("Gmail draft creation requires review") ); assert!(store.list_journal().expect("journal").is_empty()); } diff --git a/docs/cli.md b/docs/cli.md index b32939b9..4e53f155 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -86,7 +86,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/`, and `draft/` folders. `inbox/` and `sent/` are read-only; create a Markdown file directly under `draft/` to send mail on push. +`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/`, and `draft/` folders. `inbox/` and `sent/` are read-only; create a Markdown file directly under `draft/` to create an unsent Gmail UI draft on push. Gmail mount options: @@ -858,8 +858,8 @@ 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/`. Push creates a Gmail draft and immediately sends it; push is the send -action. Gmail draft files require `to` frontmatter and either `subject` or +`draft/`. Push creates an unsent Gmail draft; send it later from the Gmail UI. +Gmail draft files require `to` frontmatter and either `subject` or `title`; `cc` and `bcc` are optional. Nested draft files and edits or deletes in `inbox/` and `sent/` are rejected. diff --git a/docs/gmail-connector.md b/docs/gmail-connector.md index 0af946a6..b8445ae2 100644 --- a/docs/gmail-connector.md +++ b/docs/gmail-connector.md @@ -67,8 +67,9 @@ CLI overrides: ## Projection And Pull By default, Pull enumerates the recent 100 inbox messages and recent 100 sent -messages. The `draft/` folder is created locally, but the connector does not -enumerate remote Gmail drafts. +messages. The `draft/` folder is the local staging surface for new Gmail drafts. +When pushed, a local draft becomes an unsent Gmail draft and is visible in the +Gmail UI. Remote drafts created outside Locality are not enumerated yet. Gmail mounts can be registered with a date window: @@ -112,7 +113,7 @@ gmail-main/ ``` Inbox, sent, and thread content is read-only. Creating a Markdown file directly -under `draft/` remains the send surface. +under `draft/` creates an unsent Gmail draft when pushed. ## Attachments @@ -160,9 +161,9 @@ subject: Follow up Thanks for the notes. I will follow up here. ``` -`loc push` for a Gmail draft creates a Gmail draft and immediately sends it. -Push is the send action. Attachments are not supported for Gmail draft sends in -v1; `attachment` or `attachments` frontmatter is rejected. +`loc push` for a Gmail draft creates an unsent Gmail draft. Send it from the +Gmail UI after review. Attachments are not supported for Gmail draft creation +in v1; `attachment` or `attachments` frontmatter is rejected. On macOS File Provider mounts, the push journal remembers the temporary local draft identifier before sending. Once Gmail apply and read-back both succeed, @@ -190,7 +191,7 @@ Force enumeration: ./target/debug/loc pull --json "$HOME/Locality/gmail-main" ``` -Review and send a draft: +Review and create a Gmail UI draft: ```bash ./target/debug/loc status "$HOME/Locality/gmail-main/draft/reply.md" From 3d2ac8908414d3f9f4b34fea5fbe37938303cf95 Mon Sep 17 00:00:00 2001 From: Harsh Gupta Date: Wed, 22 Jul 2026 23:29:40 -0700 Subject: [PATCH 2/5] Pull remote Gmail drafts --- crates/locality-gmail/src/connector.rs | 48 ++++++++++++++++++++------ docs/gmail-connector.md | 7 ++-- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/crates/locality-gmail/src/connector.rs b/crates/locality-gmail/src/connector.rs index 3db9205b..314f4271 100644 --- a/crates/locality-gmail/src/connector.rs +++ b/crates/locality-gmail/src/connector.rs @@ -142,6 +142,14 @@ impl Connector for GmailConnector { "sent", Path::new("sent"), )?); + entries.extend(list_label_entries( + self.api.as_ref(), + &self.config.settings, + &request.mount_id, + "DRAFT", + "draft", + Path::new("draft"), + )?); return Ok(entries); } @@ -162,6 +170,14 @@ impl Connector for GmailConnector { "sent", Path::new("sent"), )?); + entries.extend(list_label_entries( + self.api.as_ref(), + &self.config.settings, + &request.mount_id, + "DRAFT", + "draft", + Path::new("draft"), + )?); Ok(entries) } @@ -217,7 +233,14 @@ impl Connector for GmailConnector { ChildContainer::DirectoryChildren(remote_id) if remote_id.as_str() == DRAFT_FOLDER_ID => { - Vec::new() + list_label_entries( + self.api.as_ref(), + &self.config.settings, + &request.mount_id, + "DRAFT", + "draft", + &request.parent_path, + )? } ChildContainer::PageChildren(remote_id) => { let Some((mailbox, thread_id)) = parse_thread_remote_id(&remote_id) else { @@ -1209,7 +1232,7 @@ mod tests { use crate::settings::GmailMountSettings; #[test] - fn enumerate_projects_three_folders_and_recent_inbox_sent_messages() { + fn enumerate_projects_three_folders_and_recent_inbox_sent_draft_messages() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); @@ -1237,12 +1260,10 @@ 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") && entry.path.components().count() > 1)); + assert!(entries.iter().any(|entry| entry.path.starts_with("draft/"))); assert_eq!( api.calls.lock().expect("calls").list_max_results, - vec![100, 100] + vec![100, 100, 100] ); } @@ -1306,11 +1327,12 @@ 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.list_page_tokens, - vec![None, Some("next-inbox".to_string()), None] + vec![None, Some("next-inbox".to_string()), None, None] ); } @@ -1327,8 +1349,8 @@ mod tests { .expect("enumerate"); let calls = api.calls.lock().expect("calls"); - assert_eq!(calls.list_max_results, vec![100, 100]); - assert_eq!(calls.list_page_tokens, vec![None, None]); + assert_eq!(calls.list_max_results, vec![100, 100, 100]); + assert_eq!(calls.list_page_tokens, vec![None, None, None]); assert!(calls.list_queries.is_empty()); } @@ -1412,7 +1434,7 @@ mod tests { } #[test] - fn list_children_for_draft_folder_returns_empty_remote_entries() { + fn list_children_for_draft_folder_returns_remote_drafts() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api); @@ -1424,7 +1446,8 @@ mod tests { }) .expect("list draft"); - assert!(result.entries.is_empty()); + assert_eq!(result.entries.len(), 1); + assert!(result.entries[0].path.starts_with("draft/")); } #[test] @@ -2252,6 +2275,7 @@ mod tests { let id = match label_id { "INBOX" => "inbox-msg-1", "SENT" => "sent-msg-1", + "DRAFT" => "draft-msg-1", other => panic!("unexpected label {other}"), }; Ok(GmailMessageList { @@ -2385,6 +2409,8 @@ mod tests { fn message_fixture(id: &str) -> GmailMessage { let labels = if id.starts_with("sent") { Some(vec!["SENT".to_string()]) + } else if id.starts_with("draft") { + Some(vec!["DRAFT".to_string()]) } else { Some(vec!["INBOX".to_string()]) }; diff --git a/docs/gmail-connector.md b/docs/gmail-connector.md index b8445ae2..e478506f 100644 --- a/docs/gmail-connector.md +++ b/docs/gmail-connector.md @@ -67,9 +67,10 @@ CLI overrides: ## Projection And Pull By default, Pull enumerates the recent 100 inbox messages and recent 100 sent -messages. The `draft/` folder is the local staging surface for new Gmail drafts. -When pushed, a local draft becomes an unsent Gmail draft and is visible in the -Gmail UI. Remote drafts created outside Locality are not enumerated yet. +messages and recent 100 Gmail drafts. The `draft/` folder is the local staging surface +for new Gmail drafts. When pushed, a local draft becomes an unsent Gmail draft +and is visible in the Gmail UI; drafts created in Gmail are pulled into this +folder too. Gmail mounts can be registered with a date window: From ab0fa1934ff8930c981ede8e51922deae5665b9f Mon Sep 17 00:00:00 2001 From: Harsh Gupta Date: Thu, 23 Jul 2026 00:37:16 -0700 Subject: [PATCH 3/5] Make Gmail thread replies first-class --- crates/loc-cli/src/commands.rs | 93 +- crates/loc-cli/src/create.rs | 323 ++++- crates/loc-cli/tests/create.rs | 201 ++- crates/loc-cli/tests/mount.rs | 19 +- crates/locality-core/src/push.rs | 22 +- crates/locality-core/tests/push_executor.rs | 35 + crates/locality-gmail/src/client.rs | 235 +++- crates/locality-gmail/src/connector.rs | 1305 ++++++++++++++++--- crates/locality-gmail/src/dto.rs | 16 +- crates/locality-gmail/src/oauth.rs | 2 +- crates/locality-gmail/src/render.rs | 382 +++++- crates/locality-gmail/src/settings.rs | 132 +- crates/localityd/src/gmail.rs | 245 +++- crates/localityd/src/notion.rs | 29 +- crates/localityd/src/push.rs | 84 +- crates/localityd/src/source.rs | 19 +- crates/localityd/src/virtual_fs.rs | 54 +- crates/localityd/tests/push_execution.rs | 543 ++++---- crates/localityd/tests/source_descriptor.rs | 171 ++- docs/cli.md | 42 +- docs/gmail-connector.md | 72 +- 21 files changed, 3348 insertions(+), 676 deletions(-) diff --git a/crates/loc-cli/src/commands.rs b/crates/loc-cli/src/commands.rs index 919dcf91..c41ce858 100644 --- a/crates/loc-cli/src/commands.rs +++ b/crates/loc-cli/src/commands.rs @@ -83,8 +83,9 @@ use crate::connector::{ resolve_source_for_mount_id, resolve_source_for_path, source_descriptor, source_display_name, }; use crate::create::{ - CreateDatabaseOptions, CreateDatabaseReport, CreateError, CreatePageOptions, CreatePageReport, - run_create_database, run_create_page, + CreateDatabaseOptions, CreateDatabaseReport, CreateError, CreateGmailReplyOptions, + CreateGmailReplyReport, CreatePageOptions, CreatePageReport, run_create_database, + run_create_gmail_reply, run_create_page, }; use crate::daemon::{DaemonControlError, DaemonControlReport, run_daemon_control}; use crate::diff::{DiffError, run_diff_with_state_root}; @@ -703,7 +704,7 @@ struct MountGmailArgs { #[arg( long, value_name = "messages|threads", - help = "Gmail projection view. Defaults to messages." + help = "Gmail projection view. Defaults to threads." )] view: Option, } @@ -914,6 +915,23 @@ enum CreateCommand { Page(CreatePageArgs), #[command(about = "Create a Notion database directory with a draft _schema.yaml")] Database(CreateDatabaseArgs), + #[command(about = "Create a canonical Gmail draft that replies to a thread")] + GmailReply(CreateGmailReplyArgs), +} + +#[derive(Debug, Args)] +struct CreateGmailReplyArgs { + #[arg( + value_name = "thread-dir", + help = "Hydrated Gmail thread directory (or its page.md)." + )] + thread: String, + #[arg( + long, + value_name = "message", + help = "Reply to a specific child path, filename, or Gmail message id. Defaults to the latest message." + )] + message: Option, } #[derive(Debug, Args)] @@ -1550,6 +1568,11 @@ fn legacy_args_for_command(command: &LocalityCommand) -> Vec { push_flag_value(&mut args, "--title", &options.title); push_optional_flag_value(&mut args, "--parent", options.parent.as_deref()); } + CreateCommand::GmailReply(options) => { + args.push("gmail-reply".to_string()); + args.push(options.thread.clone()); + push_optional_flag_value(&mut args, "--message", options.message.as_deref()); + } } } LocalityCommand::Templates { command } => { @@ -3633,11 +3656,7 @@ fn gmail_mount_settings_json(args: &[String]) -> Result { .map_err(|error| { CommandError::new("mount", "gmail_view_invalid", locality_error_message(error)) })? - .unwrap_or(GmailProjectionView::Messages); - - if after.is_none() && before.is_none() && view == GmailProjectionView::Messages { - return Ok("{}".to_string()); - } + .unwrap_or(GmailProjectionView::Threads); let settings = match (after, before) { (None, None) => GmailMountSettings::default().with_view(view), @@ -5167,18 +5186,60 @@ fn create(args: &[String], json: bool) -> i32 { match first_positional(args) { Some("page") => create_page(args, json), Some("database") => create_database(args, json), + Some("gmail-reply") => create_gmail_reply(args, json), _ => command_error( json, CommandError::new( "create", "usage", - "usage: loc create [options] [--json]", + "usage: loc create [options] [--json]", ), EXIT_USAGE, ), } } +fn create_gmail_reply(args: &[String], json: bool) -> i32 { + let Some(thread) = nth_positional(args, 1) else { + return command_error( + json, + CommandError::new( + "create_gmail_reply", + "missing_thread", + "a Gmail thread directory is required", + ), + EXIT_USAGE, + ); + }; + let state_root = default_state_root(); + let mut store = match SqliteStateStore::open(state_root.clone()) { + Ok(store) => store, + Err(error) => { + return command_error( + json, + CommandError::new("create_gmail_reply", "store_open_failed", error.to_string()), + EXIT_INTERNAL, + ); + } + }; + let options = CreateGmailReplyOptions { + thread: PathBuf::from(thread), + message: flag_value(args, "--message").map(str::to_string), + state_root: Some(state_root), + }; + match run_create_gmail_reply(&mut store, options) { + Ok(report) if json => { + print_json(&report); + EXIT_SUCCESS + } + Ok(report) => { + print_create_gmail_reply_report(&report); + EXIT_SUCCESS + } + Err(error) => create_command_error(json, "create_gmail_reply", error), + } +} + fn create_database(args: &[String], json: bool) -> i32 { let Some(title) = flag_value(args, "--title").map(str::to_string) else { return command_error( @@ -6944,6 +7005,17 @@ fn print_create_database_report(report: &CreateDatabaseReport) { } } +fn print_create_gmail_reply_report(report: &CreateGmailReplyReport) { + println!("created Gmail reply draft {}", report.path); + println!(" thread: {}", report.thread_id); + println!(" replying to: {}", report.reply_to_message_id); + println!(" recipient: {}", report.recipient); + println!(" next:"); + for next in &report.next { + println!(" {next}"); + } +} + fn print_okf_export_report(report: &OkfExportReport) { println!("exported OKF bundle {}", report.output); println!(" source: {}", report.source); @@ -8821,6 +8893,8 @@ fn create_command_error(json: bool, command: &'static str, error: CreateError) - | CreateError::MountNotFound(_) | CreateError::PrivateUnsupported { .. } | CreateError::DatabaseUnsupported { .. } + | CreateError::GmailReplyUnsupported { .. } + | CreateError::InvalidReply(_) | CreateError::ReadOnlyMount { .. } | CreateError::ReadOnlySource { .. } | CreateError::TargetExists(_) => EXIT_USAGE, @@ -9410,6 +9484,7 @@ fn takes_value(arg: &str) -> bool { | "--limit" | "--title" | "--parent" + | "--message" | "--push-id" ) } diff --git a/crates/loc-cli/src/create.rs b/crates/loc-cli/src/create.rs index 506dad54..c3970e26 100644 --- a/crates/loc-cli/src/create.rs +++ b/crates/loc-cli/src/create.rs @@ -3,8 +3,9 @@ //! Creation stays filesystem-first: this module writes the draft shape that //! push and Live Mode already understand. It does not call remote connectors. -use std::fs; +use std::fs::{self, OpenOptions}; use std::io; +use std::io::Write as _; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -19,7 +20,9 @@ use locality_store::{ use localityd::file_provider; use localityd::source::{source_create_decision_for_parent_path, source_display_name}; use localityd::virtual_fs::virtual_fs_content_path; -use serde::Serialize; +use serde::{Deserialize, Serialize}; + +const MAX_GMAIL_REPLY_FILENAME_BYTES: usize = 128; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreatePageOptions { @@ -65,6 +68,27 @@ pub struct CreateDatabaseReport { pub next: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateGmailReplyOptions { + pub thread: PathBuf, + pub message: Option, + pub state_root: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CreateGmailReplyReport { + pub ok: bool, + pub command: &'static str, + pub kind: &'static str, + pub path: String, + pub mount_id: String, + pub thread_id: String, + pub reply_to_message_id: String, + pub recipient: String, + pub subject: String, + pub next: Vec, +} + pub fn run_create_page( store: &mut S, options: CreatePageOptions, @@ -266,6 +290,291 @@ where }) } +pub fn run_create_gmail_reply( + store: &mut S, + options: CreateGmailReplyOptions, +) -> Result +where + S: EntityRepository + MountRepository + VirtualMutationRepository, +{ + let mut thread = absolute_path(&options.thread)?; + if thread.file_name().and_then(|name| name.to_str()) == Some(PAGE_DOCUMENT_FILENAME) { + thread = thread.parent().map(Path::to_path_buf).ok_or_else(|| { + CreateError::InvalidReply("thread page has no parent directory".into()) + })?; + } + let mounts = store.load_mounts().map_err(CreateError::Store)?; + let (mount, _) = file_provider::find_mount_for_path(&mounts, &thread) + .ok_or_else(|| CreateError::MountNotFound(thread.clone()))?; + if mount.read_only { + return Err(CreateError::ReadOnlyMount { + mount_id: mount.mount_id.0.clone(), + }); + } + if mount.connector != "gmail" { + return Err(CreateError::GmailReplyUnsupported { + connector: mount.connector.clone(), + }); + } + let relative_thread = relative_path(mount, &thread)?; + if !matches!(relative_thread.components().next(), Some(Component::Normal(folder)) if folder == "inbox" || folder == "sent") + { + return Err(CreateError::InvalidReply( + "Gmail replies must target an inbox/ or sent/ thread directory".to_string(), + )); + } + + let mut messages = Vec::new(); + for entry in fs::read_dir(&thread).map_err(|error| CreateError::WriteFile { + path: thread.clone(), + message: error.to_string(), + })? { + let entry = entry.map_err(|error| CreateError::WriteFile { + path: thread.clone(), + message: error.to_string(), + })?; + let path = entry.path(); + if path.file_name().and_then(|name| name.to_str()) == Some(PAGE_DOCUMENT_FILENAME) + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + { + continue; + } + let content = fs::read_to_string(&path).map_err(|error| CreateError::WriteFile { + path: path.clone(), + message: error.to_string(), + })?; + if let Some(frontmatter) = markdown_frontmatter(&content) { + let parsed = + yaml_serde::from_str::(frontmatter).map_err(|error| { + CreateError::InvalidReply(format!( + "cannot read Gmail metadata from `{}`: {error}", + path.display() + )) + })?; + if parsed.gmail.thread_id.trim().is_empty() + || parsed.gmail.message_id.trim().is_empty() + || parsed.gmail.rfc_message_id.trim().is_empty() + { + continue; + } + messages.push(ReplyMessage { path, parsed }); + } + } + if messages.is_empty() { + return Err(CreateError::InvalidReply( + "thread has no hydrated message files with Gmail reply metadata; open or pull a message first" + .to_string(), + )); + } + + let selected = if let Some(selector) = options.message.as_deref() { + let selector_path = Path::new(selector); + messages + .iter() + .find(|message| { + message.path == selector_path + || message.path.file_name() == selector_path.file_name() + || message.path.strip_prefix(&thread).ok() == Some(selector_path) + || message.parsed.gmail.message_id == selector + }) + .ok_or_else(|| { + CreateError::InvalidReply(format!( + "no hydrated message in the thread matches `{selector}`" + )) + })? + } else { + messages + .iter() + .max_by(|left, right| reply_message_order(left).cmp(&reply_message_order(right))) + .expect("messages is not empty") + }; + + let thread_id = selected.parsed.gmail.thread_id.trim().to_string(); + if messages + .iter() + .any(|message| message.parsed.gmail.thread_id.trim() != thread_id) + { + return Err(CreateError::InvalidReply( + "thread directory contains messages from different Gmail threads".to_string(), + )); + } + let recipient = selected + .parsed + .gmail + .reply_to + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&selected.parsed.from) + .trim() + .to_string(); + if recipient.is_empty() { + return Err(CreateError::InvalidReply( + "selected message has neither Reply-To nor From metadata".to_string(), + )); + } + let subject = selected.parsed.subject.trim().to_string(); + if subject.is_empty() { + return Err(CreateError::InvalidReply( + "selected message has no subject".to_string(), + )); + } + let rfc_message_id = selected.parsed.gmail.rfc_message_id.trim().to_string(); + let mut references = selected.parsed.gmail.references.clone(); + if !references + .iter() + .any(|reference| reference == &rfc_message_id) + { + references.push(rfc_message_id.clone()); + } + let body = gmail_reply_markdown( + &recipient, + &subject, + &thread_id, + &selected.parsed.gmail.message_id, + &rfc_message_id, + &references, + ); + let draft_dir = mount.root.join("draft"); + let draft_path = unique_reply_draft_path(&draft_dir, &subject); + if mount.projection.uses_virtual_filesystem() { + let state_root = options + .state_root + .as_deref() + .ok_or(CreateError::VirtualStateRootRequired)?; + stage_virtual_file( + store, + mount, + state_root, + &draft_path, + &body, + Some(RemoteId::new("gmail-folder:draft")), + )?; + } else { + fs::create_dir_all(&draft_dir).map_err(|error| CreateError::WriteFile { + path: draft_dir.clone(), + message: error.to_string(), + })?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&draft_path) + .map_err(|error| CreateError::WriteFile { + path: draft_path.clone(), + message: error.to_string(), + })?; + file.write_all(body.as_bytes()) + .map_err(|error| CreateError::WriteFile { + path: draft_path.clone(), + message: error.to_string(), + })?; + } + + let path = draft_path.display().to_string(); + Ok(CreateGmailReplyReport { + ok: true, + command: "create_gmail_reply", + kind: "gmail_reply_draft", + path: path.clone(), + mount_id: mount.mount_id.0.clone(), + thread_id, + reply_to_message_id: selected.parsed.gmail.message_id.clone(), + recipient, + subject, + next: vec![ + format!("loc diff {}", shell_quote_path(&path)), + format!("loc push {} -y", shell_quote_path(&path)), + ], + }) +} + +#[derive(Debug, Deserialize)] +struct ReplyMessageFrontmatter { + #[serde(default)] + from: String, + #[serde(default)] + subject: String, + gmail: ReplyGmailFrontmatter, +} + +#[derive(Debug, Deserialize)] +struct ReplyGmailFrontmatter { + message_id: String, + thread_id: String, + rfc_message_id: String, + reply_to: Option, + #[serde(default)] + references: Vec, + internal_date: Option, +} + +struct ReplyMessage { + path: PathBuf, + parsed: ReplyMessageFrontmatter, +} + +fn markdown_frontmatter(markdown: &str) -> Option<&str> { + let rest = markdown.strip_prefix("---\n")?; + let end = rest.find("\n---")?; + Some(&rest[..end]) +} + +fn reply_message_order(message: &ReplyMessage) -> (u64, String) { + let internal_date = message + .parsed + .gmail + .internal_date + .as_deref() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + (internal_date, message.path.display().to_string()) +} + +fn unique_reply_draft_path(draft_dir: &Path, subject: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let prefix = format!("reply-{stamp}-{counter}-"); + let suffix = ".md"; + let slug_budget = MAX_GMAIL_REPLY_FILENAME_BYTES + .saturating_sub(prefix.len()) + .saturating_sub(suffix.len()); + let mut slug = page_directory_name_for_title(subject); + if slug.len() > slug_budget { + let mut end = slug_budget; + while !slug.is_char_boundary(end) { + end = end.saturating_sub(1); + } + slug.truncate(end); + } + draft_dir.join(format!("{prefix}{slug}{suffix}")) +} + +fn gmail_reply_markdown( + recipient: &str, + subject: &str, + thread_id: &str, + reply_to_message_id: &str, + in_reply_to: &str, + references: &[String], +) -> String { + let mut output = format!( + "---\nto: {}\nsubject: {}\ngmail:\n thread_id: {}\n reply_to_message_id: {}\n in_reply_to: {}\n references:\n", + yaml_double_quoted(recipient), + yaml_double_quoted(subject), + yaml_double_quoted(thread_id), + yaml_double_quoted(reply_to_message_id), + yaml_double_quoted(in_reply_to), + ); + for reference in references { + output.push_str(&format!(" - {}\n", yaml_double_quoted(reference))); + } + output.push_str("---\n\n"); + output +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CreateError { CurrentDir { @@ -279,6 +588,10 @@ pub enum CreateError { DatabaseUnsupported { connector: String, }, + GmailReplyUnsupported { + connector: String, + }, + InvalidReply(String), InvalidParent { path: PathBuf, message: String, @@ -308,6 +621,8 @@ impl CreateError { Self::MountNotFound(_) => "mount_not_found", Self::PrivateUnsupported { .. } => "private_unsupported", Self::DatabaseUnsupported { .. } => "database_unsupported", + Self::GmailReplyUnsupported { .. } => "gmail_reply_unsupported", + Self::InvalidReply(_) => "invalid_gmail_reply", Self::InvalidParent { .. } => "invalid_parent", Self::ReadOnlyMount { .. } => "read_only_mount", Self::ReadOnlySource { .. } => "read_only_source", @@ -333,6 +648,10 @@ impl CreateError { Self::DatabaseUnsupported { connector } => { format!("database creation is only supported for Notion mounts, not `{connector}`") } + Self::GmailReplyUnsupported { connector } => { + format!("Gmail replies are only supported for Gmail mounts, not `{connector}`") + } + Self::InvalidReply(message) => message.clone(), Self::InvalidParent { path, message } => { format!("cannot create inside `{}`: {message}", path.display()) } diff --git a/crates/loc-cli/tests/create.rs b/crates/loc-cli/tests/create.rs index 3fbf993d..93887856 100644 --- a/crates/loc-cli/tests/create.rs +++ b/crates/loc-cli/tests/create.rs @@ -5,7 +5,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use loc_cli::create::{ - CreateDatabaseOptions, CreateError, CreatePageOptions, run_create_database, run_create_page, + CreateDatabaseOptions, CreateError, CreateGmailReplyOptions, CreatePageOptions, + run_create_database, run_create_gmail_reply, run_create_page, }; use locality_core::model::{EntityKind, MountId, RemoteId}; use locality_store::{ @@ -450,6 +451,187 @@ fn create_page_rejects_titles_that_are_paths() { assert!(matches!(error, CreateError::InvalidTitle(_))); } +#[test] +fn create_gmail_reply_writes_a_threaded_draft_from_the_selected_message() { + let fixture = CreateFixture::new("loc-create-gmail-reply"); + let mut store = fixture.gmail_store(ProjectionMode::PlainFiles, false); + let thread = fixture.root.join("inbox/Quarterly update"); + fs::create_dir_all(&thread).expect("thread directory"); + fs::write( + thread.join("page.md"), + "---\ntitle: Quarterly update\n---\n", + ) + .expect("thread page"); + fs::write( + thread.join("2026-07-02-update.md"), + "---\nfrom: \"Earlier \"\nsubject: \"Quarterly update\"\ngmail:\n message_id: \"message-1\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n references:\n - \"\"\n---\nEarlier message\n", + ) + .expect("first message"); + fs::write( + thread.join("2026-07-03-reply.md"), + "---\nfrom: \"Latest \"\nsubject: \"Quarterly update\"\ngmail:\n message_id: \"message-2\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n reply_to: \"reply@example.com\"\n references:\n - \"\"\n---\nLatest message\n", + ) + .expect("latest message"); + + let report = run_create_gmail_reply( + &mut store, + CreateGmailReplyOptions { + thread: thread.clone(), + message: Some("2026-07-02-update.md".to_string()), + state_root: None, + }, + ) + .expect("create Gmail reply"); + + assert_eq!(report.command, "create_gmail_reply"); + assert_eq!(report.thread_id, "thread-1"); + assert_eq!(report.reply_to_message_id, "message-1"); + assert_eq!(report.recipient, "Earlier "); + assert_eq!(report.subject, "Quarterly update"); + let draft = fs::read_to_string(&report.path).expect("reply draft"); + assert_eq!( + draft, + "---\nto: \"Earlier \"\nsubject: \"Quarterly update\"\ngmail:\n thread_id: \"thread-1\"\n reply_to_message_id: \"message-1\"\n in_reply_to: \"\"\n references:\n - \"\"\n - \"\"\n---\n\n" + ); + assert!(Path::new(&report.path).starts_with(fixture.root.join("draft"))); +} + +#[test] +fn create_gmail_reply_selects_a_message_by_gmail_message_id() { + let fixture = CreateFixture::new("loc-create-gmail-reply-message-id"); + let mut store = fixture.gmail_store(ProjectionMode::PlainFiles, false); + let thread = fixture.root.join("inbox/Quarterly update"); + fs::create_dir_all(&thread).expect("thread directory"); + fs::write( + thread.join("2026-07-02-update.md"), + "---\nfrom: \"Earlier \"\nsubject: \"Quarterly update\"\ngmail:\n message_id: \"gmail-message-1\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n---\nEarlier message\n", + ) + .expect("first message"); + fs::write( + thread.join("2026-07-03-update.md"), + "---\nfrom: \"Later \"\nsubject: \"Quarterly update\"\ngmail:\n message_id: \"gmail-message-2\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n---\nLater message\n", + ) + .expect("second message"); + + let report = run_create_gmail_reply( + &mut store, + CreateGmailReplyOptions { + thread, + message: Some("gmail-message-1".to_string()), + state_root: None, + }, + ) + .expect("create reply from Gmail message ID"); + + assert_eq!(report.reply_to_message_id, "gmail-message-1"); + assert_eq!(report.recipient, "Earlier "); +} + +#[test] +fn create_gmail_reply_uses_unique_bounded_draft_filenames() { + let fixture = CreateFixture::new("loc-create-gmail-reply-filenames"); + let mut store = fixture.gmail_store(ProjectionMode::PlainFiles, false); + let thread = fixture.root.join("inbox/Long subject"); + fs::create_dir_all(&thread).expect("thread directory"); + let subject = "📬 A very long subject ".repeat(20); + fs::write( + thread.join("message.md"), + format!( + "---\nfrom: \"sender@example.com\"\nsubject: {subject:?}\ngmail:\n message_id: \"message-1\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n---\nMessage\n" + ), + ) + .expect("message"); + + let first = run_create_gmail_reply( + &mut store, + CreateGmailReplyOptions { + thread: thread.clone(), + message: None, + state_root: None, + }, + ) + .expect("first reply"); + let second = run_create_gmail_reply( + &mut store, + CreateGmailReplyOptions { + thread, + message: None, + state_root: None, + }, + ) + .expect("second reply"); + + assert_ne!(first.path, second.path); + for path in [&first.path, &second.path] { + let filename = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .expect("draft filename"); + assert!( + filename.len() <= 128, + "filename was not bounded: {filename}" + ); + assert!( + filename.starts_with("reply-"), + "unexpected filename: {filename}" + ); + } +} + +#[test] +fn cli_create_gmail_reply_defaults_to_the_latest_thread_message() { + let fixture = CreateFixture::new("loc-create-gmail-reply-cli"); + let state_root = fixture.temp.path("state"); + let mut store = SqliteStateStore::open(state_root.clone()).expect("sqlite"); + store + .save_mount(MountConfig { + mount_id: MountId::new("gmail-main"), + connector: "gmail".to_string(), + root: fixture.root.clone(), + remote_root_id: None, + connection_id: None, + read_only: false, + projection: ProjectionMode::PlainFiles, + settings_json: "{}".to_string(), + }) + .expect("save Gmail mount"); + let thread = fixture.root.join("inbox/Quarterly update"); + fs::create_dir_all(&thread).expect("thread directory"); + fs::write( + thread.join("2026-07-03-latest.md"), + "---\nfrom: \"Latest \"\nsubject: \"Quarterly update\"\ngmail:\n message_id: \"message-2\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n---\nLatest message\n", + ) + .expect("message"); + + let output = Command::new(env!("CARGO_BIN_EXE_loc")) + .env("LOCALITY_STATE_DIR", &state_root) + .args([ + "create", + "gmail-reply", + thread.to_str().expect("thread path"), + "--json", + ]) + .output() + .expect("loc create gmail-reply"); + + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let report: Value = serde_json::from_slice(&output.stdout).expect("JSON report"); + assert_eq!(report["command"], "create_gmail_reply"); + assert_eq!(report["thread_id"], "thread-1"); + assert_eq!(report["reply_to_message_id"], "message-2"); + assert_eq!(report["recipient"], "Latest "); + let draft = fs::read_to_string(report["path"].as_str().expect("draft path")).expect("draft"); + assert!( + draft.contains("in_reply_to: \"\""), + "{draft}" + ); +} + struct CreateFixture { temp: TestTempDir, root: PathBuf, @@ -500,6 +682,23 @@ impl CreateFixture { .expect("save mount"); store } + + fn gmail_store(&self, projection: ProjectionMode, read_only: bool) -> InMemoryStateStore { + let mut store = InMemoryStateStore::new(); + store + .save_mount(MountConfig { + mount_id: MountId::new("gmail-main"), + connector: "gmail".to_string(), + root: self.root.clone(), + remote_root_id: None, + connection_id: None, + read_only, + projection, + settings_json: "{}".to_string(), + }) + .expect("save Gmail mount"); + store + } } struct TestTempDir { diff --git a/crates/loc-cli/tests/mount.rs b/crates/loc-cli/tests/mount.rs index b22a4cd0..7831a279 100644 --- a/crates/loc-cli/tests/mount.rs +++ b/crates/loc-cli/tests/mount.rs @@ -925,7 +925,7 @@ fn cli_mount_gmail_persists_date_window_and_thread_view() { assert_eq!(report["connector"], "gmail", "{report:#?}"); assert_eq!( report["settings_json"], - r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15"},"view":"threads"}}"#, + r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15"},"view":"threads"},"projection_layout_version":2}"#, "{report:#?}" ); @@ -936,7 +936,7 @@ fn cli_mount_gmail_persists_date_window_and_thread_view() { .expect("mount exists"); assert_eq!( mount.settings_json, - r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15"},"view":"threads"}}"# + r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15"},"view":"threads"},"projection_layout_version":2}"# ); } @@ -1184,7 +1184,7 @@ fn cli_mount_gmail_rejects_reversed_or_equal_date_windows_with_detail() { } #[test] -fn cli_mount_gmail_default_settings_are_suppressed() { +fn cli_mount_gmail_default_settings_persist_thread_layout_version() { let fixture = MountFixture::new("loc-cli-gmail-default-settings"); fs::create_dir_all(&fixture.root).expect("create fixture root"); let state_root = fixture.root.join("state"); @@ -1207,7 +1207,9 @@ fn cli_mount_gmail_default_settings_are_suppressed() { "--json", ])); - assert_eq!(report["settings_json"], "{}", "{report:#?}"); + let expected_settings = + r#"{"gmail":{"date_window":null,"view":"threads"},"projection_layout_version":2}"#; + assert_eq!(report["settings_json"], expected_settings, "{report:#?}"); let human_mount_root = fixture.root.join("gmail-human"); let human_mount_root_arg = human_mount_root.display().to_string(); @@ -1232,7 +1234,10 @@ fn cli_mount_gmail_default_settings_are_suppressed() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(!stdout.contains("settings:"), "{stdout}"); + assert!( + stdout.contains(&format!("settings: {expected_settings}")), + "{stdout}" + ); let store = SqliteStateStore::open(state_root).expect("open state"); let json_mount = store @@ -1243,8 +1248,8 @@ fn cli_mount_gmail_default_settings_are_suppressed() { .get_mount(&MountId::new("gmail-human")) .expect("load human mount") .expect("human mount exists"); - assert_eq!(json_mount.settings_json, "{}"); - assert_eq!(human_mount.settings_json, "{}"); + assert_eq!(json_mount.settings_json, expected_settings); + assert_eq!(human_mount.settings_json, expected_settings); } #[test] diff --git a/crates/locality-core/src/push.rs b/crates/locality-core/src/push.rs index 9eccb9d3..fe64a8a9 100644 --- a/crates/locality-core/src/push.rs +++ b/crates/locality-core/src/push.rs @@ -344,8 +344,12 @@ pub fn plan_push_pipeline(request: PushPipelineRequest<'_>) -> PushPipelineResul #[derive(Clone, Debug, PartialEq, Eq)] pub struct PushExecutionRequest { - /// Stable push identifier used by journals and connector idempotency keys. + /// Stable push identifier used by the local journal and, by default, + /// connector idempotency keys. pub push_id: PushId, + /// Optional prior push identifier used solely to derive connector + /// idempotency keys when resuming an incomplete operation. + pub idempotency_push_id: Option, /// Mount being mutated. pub mount_id: MountId, /// Validated and approved pipeline result to execute. @@ -365,6 +369,7 @@ impl PushExecutionRequest { pub fn new(push_id: PushId, mount_id: MountId, pipeline: PushPipelineResult) -> Self { Self { push_id, + idempotency_push_id: None, mount_id, pipeline, preimages: Vec::new(), @@ -379,6 +384,11 @@ impl PushExecutionRequest { self } + pub fn with_idempotency_push_id(mut self, push_id: PushId) -> Self { + self.idempotency_push_id = Some(push_id); + self + } + pub fn with_remote_preconditions(mut self, preconditions: Vec) -> Self { self.remote_preconditions = preconditions; self @@ -550,12 +560,16 @@ where )); }; let remote_ids = plan.affected_entities.clone(); + let idempotency_push_id = request + .idempotency_push_id + .as_ref() + .unwrap_or(&request.push_id); let operation_ids = plan .operations .iter() .enumerate() .map(|(index, operation)| { - PushOperationId::for_operation(&request.push_id, index, operation) + PushOperationId::for_operation(idempotency_push_id, index, operation) }) .collect::>(); @@ -574,7 +588,7 @@ where host.update_status(&request.push_id, JournalStatus::Applying)?; if let Err(error) = host.check(PushConcurrencyRequest { - push_id: &request.push_id, + push_id: idempotency_push_id, mount_id: &request.mount_id, plan: &plan, operation_ids: &operation_ids, @@ -586,7 +600,7 @@ where } let apply_result = match host.apply(PushApplyRequest { - push_id: &request.push_id, + push_id: idempotency_push_id, mount_id: &request.mount_id, plan: &plan, operation_ids: &operation_ids, diff --git a/crates/locality-core/tests/push_executor.rs b/crates/locality-core/tests/push_executor.rs index 900eba2d..f01b2902 100644 --- a/crates/locality-core/tests/push_executor.rs +++ b/crates/locality-core/tests/push_executor.rs @@ -90,6 +90,41 @@ fn executor_copies_request_metadata_and_readable_diff_to_journal() { assert_eq!(entry.readable_diff, Some(readable_diff)); } +#[test] +fn executor_can_reuse_connector_idempotency_without_reusing_the_local_journal_id() { + let events = event_log(); + let mut host = RecordingHost::new(events); + let local_push_id = PushId("push-retry".to_string()); + let original_push_id = PushId("push-original".to_string()); + + let result = execute_journaled_push_with_host( + &mut host, + PushExecutionRequest::new(local_push_id.clone(), mount_id(), approved_pipeline()) + .with_idempotency_push_id(original_push_id.clone()), + ) + .expect("retry execution"); + + assert_eq!(result.push_id, local_push_id); + assert_eq!( + host.journal.entry.as_ref().expect("journal").push_id, + local_push_id + ); + assert_eq!( + host.concurrency.seen_push_id, + Some(original_push_id.clone()) + ); + assert_eq!(host.applier.seen_push_id, Some(original_push_id.clone())); + assert_eq!(host.reconciler.seen_push_id, Some(local_push_id)); + let JournalApplyEffect::UpdatedBlock { operation_id, .. } = &result.apply_effects[0] else { + panic!("expected updated block effect") + }; + assert!( + operation_id + .0 + .starts_with(&format!("{}:", original_push_id.0)) + ); +} + #[test] fn executor_does_not_journal_or_apply_until_pipeline_is_approved() { let events = event_log(); diff --git a/crates/locality-gmail/src/client.rs b/crates/locality-gmail/src/client.rs index de3a386c..558812b3 100644 --- a/crates/locality-gmail/src/client.rs +++ b/crates/locality-gmail/src/client.rs @@ -10,7 +10,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use crate::dto::{ - GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList, + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailMessage, GmailMessageList, GmailMessagePartBody, GmailThread, GmailThreadList, }; @@ -38,13 +38,24 @@ pub trait GmailApi: std::fmt::Debug + Send + Sync { fn get_message_full(&self, message_id: &str) -> LocalityResult; fn get_thread_metadata(&self, thread_id: &str) -> LocalityResult; fn get_thread_full(&self, thread_id: &str) -> LocalityResult; + fn list_drafts( + &self, + max_results: u32, + page_token: Option<&str>, + ) -> LocalityResult; + fn get_draft_metadata(&self, draft_id: &str) -> LocalityResult; + fn get_draft_full(&self, draft_id: &str) -> LocalityResult; fn get_attachment( &self, message_id: &str, attachment_id: &str, ) -> LocalityResult; fn create_draft(&self, request: GmailDraftCreateRequest) -> LocalityResult; - fn send_draft(&self, request: GmailDraftSendRequest) -> LocalityResult; + fn update_draft( + &self, + draft_id: &str, + request: GmailDraftCreateRequest, + ) -> LocalityResult; } #[derive(Clone)] @@ -110,6 +121,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 { @@ -155,7 +181,18 @@ impl GmailApi for HttpGmailApiClient { fn get_message_metadata(&self, message_id: &str) -> LocalityResult { let mut query = vec![("format".to_string(), "metadata".to_string())]; - for header in ["From", "To", "Cc", "Bcc", "Subject", "Date", "Message-ID"] { + for header in [ + "From", + "Reply-To", + "To", + "Cc", + "Bcc", + "Subject", + "Date", + "Message-ID", + "References", + "In-Reply-To", + ] { query.push(("metadataHeaders".to_string(), header.to_string())); } self.get_json(&format!("/users/me/messages/{message_id}"), query) @@ -171,7 +208,18 @@ impl GmailApi for HttpGmailApiClient { fn get_thread_metadata(&self, thread_id: &str) -> LocalityResult { let thread_id = percent_encode_path_segment(thread_id); let mut query = vec![("format".to_string(), "metadata".to_string())]; - for header in ["From", "To", "Cc", "Bcc", "Subject", "Date", "Message-ID"] { + for header in [ + "From", + "Reply-To", + "To", + "Cc", + "Bcc", + "Subject", + "Date", + "Message-ID", + "References", + "In-Reply-To", + ] { query.push(("metadataHeaders".to_string(), header.to_string())); } self.get_json(&format!("/users/me/threads/{thread_id}"), query) @@ -185,6 +233,46 @@ impl GmailApi for HttpGmailApiClient { ) } + fn list_drafts( + &self, + max_results: u32, + page_token: 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())); + } + self.get_json("/users/me/drafts", params) + } + + fn get_draft_metadata(&self, draft_id: &str) -> LocalityResult { + let draft_id = percent_encode_path_segment(draft_id); + let mut query = vec![("format".to_string(), "metadata".to_string())]; + for header in [ + "From", + "Reply-To", + "To", + "Cc", + "Bcc", + "Subject", + "Date", + "Message-ID", + "References", + "In-Reply-To", + ] { + query.push(("metadataHeaders".to_string(), header.to_string())); + } + self.get_json(&format!("/users/me/drafts/{draft_id}"), query) + } + + 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 get_attachment( &self, message_id: &str, @@ -202,8 +290,17 @@ impl GmailApi for HttpGmailApiClient { self.post_json_with_context("/users/me/drafts", &request, "gmail draft create") } - fn send_draft(&self, request: GmailDraftSendRequest) -> LocalityResult { - self.post_json_with_context("/users/me/drafts/send", &request, "gmail draft send") + fn update_draft( + &self, + draft_id: &str, + request: GmailDraftCreateRequest, + ) -> 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", + ) } } @@ -268,6 +365,8 @@ mod tests { use locality_core::LocalityError; + use crate::dto::{GmailDraftCreateRequest, GmailRawMessage}; + use super::{GmailApi, HttpGmailApiClient}; #[test] @@ -308,12 +407,15 @@ mod tests { metadata_headers, vec![ "metadataHeaders=From", + "metadataHeaders=Reply-To", "metadataHeaders=To", "metadataHeaders=Cc", "metadataHeaders=Bcc", "metadataHeaders=Subject", "metadataHeaders=Date", "metadataHeaders=Message-ID", + "metadataHeaders=References", + "metadataHeaders=In-Reply-To", ] ); assert!(!query.contains("From%2CTo")); @@ -392,6 +494,111 @@ mod tests { assert!(!target.contains(' '), "{target}"); } + #[test] + fn list_drafts_uses_gmail_drafts_endpoint_with_pagination() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"drafts":[{"id":"draft-1","message":{"id":"message-1"}}],"nextPageToken":"next"}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + let drafts = client.list_drafts(100, Some("page-2")).expect("draft list"); + + assert_eq!(drafts.drafts[0].id, "draft-1"); + assert_eq!(drafts.next_page_token.as_deref(), Some("next")); + let request = request_rx.recv().expect("request"); + server.join().expect("server exits"); + assert!( + request.starts_with("GET /users/me/drafts?maxResults=100&pageToken=page-2 "), + "{request}" + ); + } + + #[test] + fn get_draft_metadata_percent_encodes_id_and_requests_reply_headers() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"id":"draft/1","message":{"id":"message-1","threadId":"thread-1"}}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + let draft = client + .get_draft_metadata("draft/1 space") + .expect("draft metadata"); + + assert_eq!(draft.id, "draft/1"); + let request = request_rx.recv().expect("request"); + server.join().expect("server exits"); + let request_line = request.lines().next().expect("request line"); + assert!( + request_line.starts_with("GET /users/me/drafts/draft%2F1%20space?format=metadata&"), + "{request_line}" + ); + assert!( + request_line.contains("metadataHeaders=Reply-To"), + "{request_line}" + ); + assert!( + request_line.contains("metadataHeaders=References"), + "{request_line}" + ); + assert!( + request_line.contains("metadataHeaders=In-Reply-To"), + "{request_line}" + ); + } + + #[test] + fn get_draft_full_requests_full_format() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"id":"draft-1","message":{"id":"message-1"}}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + client.get_draft_full("draft-1").expect("draft full"); + + let request = request_rx.recv().expect("request"); + server.join().expect("server exits"); + assert!( + request.starts_with("GET /users/me/drafts/draft-1?format=full "), + "{request}" + ); + } + + #[test] + fn update_draft_puts_threaded_raw_message_to_percent_encoded_draft_path() { + let (base_url, request_rx, server) = spawn_response_server( + "HTTP/1.1 200 OK", + r#"{"id":"draft/1","message":{"id":"message-2","threadId":"thread-1"}}"#, + ); + let client = HttpGmailApiClient::with_base_url("access-token", base_url); + + let draft = client + .update_draft( + "draft/1", + GmailDraftCreateRequest { + message: GmailRawMessage { + raw: "base64url-mime".to_string(), + thread_id: Some("thread-1".to_string()), + }, + }, + ) + .expect("draft update"); + + assert_eq!(draft.message.id, "message-2"); + let request = request_rx.recv().expect("request"); + server.join().expect("server exits"); + let (headers, body) = request.split_once("\r\n\r\n").expect("request body"); + assert!( + headers.starts_with("PUT /users/me/drafts/draft%2F1 HTTP/1.1\r\n"), + "{headers}" + ); + let body: serde_json::Value = serde_json::from_str(body).expect("JSON request body"); + assert_eq!(body["message"]["raw"], "base64url-mime"); + assert_eq!(body["message"]["threadId"], "thread-1"); + } + #[test] fn http_errors_map_google_status_semantics() { assert!(matches!( @@ -482,8 +689,7 @@ mod tests { let server = thread::spawn(move || { let (mut stream, _) = listener.accept().expect("accept request"); let request = read_http_request(&mut stream); - let request_line = request.lines().next().unwrap_or_default().to_string(); - request_tx.send(request_line).expect("send request line"); + request_tx.send(request).expect("send request"); let response = format!( "{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() @@ -504,8 +710,17 @@ mod tests { break; } request.extend_from_slice(&buffer[..bytes_read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - break; + if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } } } String::from_utf8(request).expect("utf8 request") diff --git a/crates/locality-gmail/src/connector.rs b/crates/locality-gmail/src/connector.rs index 314f4271..4c7fad4c 100644 --- a/crates/locality-gmail/src/connector.rs +++ b/crates/locality-gmail/src/connector.rs @@ -22,14 +22,19 @@ use locality_core::{LocalityError, LocalityResult}; use serde::{Deserialize, Serialize}; use crate::client::{GmailApi, HttpGmailApiClient}; -use crate::dto::{GmailDraftCreateRequest, GmailMessage, GmailRawMessage, GmailThread, header_map}; +use crate::dto::{ + GmailDraft, GmailDraftCreateRequest, GmailMessage, GmailMessagePart, 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, - 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, + GmailDraftDocument, GmailDraftNativeBundle, GmailNativeBundle, GmailThreadDraftReference, + GmailThreadMessageNativeBundle, GmailThreadNativeBundle, build_draft_mime_with_message_id, + draft_remote_id, draft_remote_version, gmail_draft_document_from_message, message_frontmatter, + parse_draft_remote_id, parse_thread_message_remote_id, parse_thread_remote_id, + raw_message_base64url, remote_version, render_gmail_draft, render_gmail_message, + render_gmail_thread, render_gmail_thread_message, thread_bundle_remote_version, + thread_message_remote_id, thread_remote_id, }; use crate::settings::{GmailMountSettings, GmailProjectionView}; @@ -108,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, @@ -120,11 +125,18 @@ impl Connector for GmailConnector { } fn supported_push_operations(&self) -> BTreeSet { - [PushOperationKind::CreateEntity].into_iter().collect() + [ + PushOperationKind::CreateEntity, + PushOperationKind::UpdateEntityBody, + PushOperationKind::UpdateProperties, + ] + .into_iter() + .collect() } fn enumerate(&self, request: EnumerateRequest) -> LocalityResult> { if self.config.settings.gmail.view == GmailProjectionView::Threads { + let drafts = list_draft_metadata(self.api.as_ref(), &self.config.settings)?; let mut entries = gmail_folder_entries(&request.mount_id, Path::new("")); entries.extend(list_thread_entries( self.api.as_ref(), @@ -133,6 +145,7 @@ impl Connector for GmailConnector { "INBOX", "inbox", Path::new("inbox"), + &drafts, )?); entries.extend(list_thread_entries( self.api.as_ref(), @@ -141,15 +154,9 @@ impl Connector for GmailConnector { "SENT", "sent", Path::new("sent"), + &drafts, )?); - entries.extend(list_label_entries( - self.api.as_ref(), - &self.config.settings, - &request.mount_id, - "DRAFT", - "draft", - Path::new("draft"), - )?); + entries.extend(draft_entries(&request.mount_id, Path::new("draft"), drafts)); return Ok(entries); } @@ -170,12 +177,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) @@ -188,6 +193,7 @@ impl Connector for GmailConnector { if remote_id.as_str() == INBOX_FOLDER_ID => { if self.config.settings.gmail.view == GmailProjectionView::Threads { + let drafts = list_draft_metadata(self.api.as_ref(), &self.config.settings)?; list_thread_entries( self.api.as_ref(), &self.config.settings, @@ -195,6 +201,7 @@ impl Connector for GmailConnector { "INBOX", "inbox", &request.parent_path, + &drafts, )? } else { list_label_entries( @@ -211,6 +218,7 @@ impl Connector for GmailConnector { if remote_id.as_str() == SENT_FOLDER_ID => { if self.config.settings.gmail.view == GmailProjectionView::Threads { + let drafts = list_draft_metadata(self.api.as_ref(), &self.config.settings)?; list_thread_entries( self.api.as_ref(), &self.config.settings, @@ -218,6 +226,7 @@ impl Connector for GmailConnector { "SENT", "sent", &request.parent_path, + &drafts, )? } else { list_label_entries( @@ -233,12 +242,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, )? } @@ -323,12 +330,22 @@ impl Connector for GmailConnector { let mailbox = mailbox.to_string(); let thread_id = thread_id.to_string(); let thread = self.api.get_thread_metadata(&thread_id)?; + let associated_drafts = associated_drafts_for_thread( + list_draft_metadata(self.api.as_ref(), &self.config.settings)?, + &thread_id, + ); let entry = thread_entry( &request.mount_id, Path::new(&mailbox), &mailbox, thread.clone(), + associated_drafts.clone(), ); + let bundle = GmailThreadNativeBundle { + mailbox: mailbox.clone(), + thread: thread.clone(), + associated_drafts, + }; return Ok(RemoteObservation::new( request.mount_id, request.remote_id, @@ -337,10 +354,25 @@ impl Connector for GmailConnector { entry.path, ) .with_parent(RemoteId::new(mailbox_folder_id(&mailbox))) - .with_remote_version(RemoteVersion::new(thread_remote_version(&thread))) + .with_remote_version(RemoteVersion::new(thread_bundle_remote_version(&bundle))) .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_metadata(draft_id)?; + let entry = draft_entry(&request.mount_id, Path::new("draft"), draft.clone()); + return Ok(RemoteObservation::new( + request.mount_id, + request.remote_id, + EntityKind::Page, + entry.title, + entry.path, + ) + .with_parent(RemoteId::new(DRAFT_FOLDER_ID)) + .with_remote_version(RemoteVersion::new(draft_version(&draft))) + .with_raw_metadata_json(gmail_draft_metadata_json(&draft))); + } + 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); @@ -388,7 +420,15 @@ impl Connector for GmailConnector { let mailbox = mailbox.to_string(); let thread_id = thread_id.to_string(); let thread = self.api.get_thread_full(&thread_id)?; - let bundle = GmailThreadNativeBundle { mailbox, thread }; + let associated_drafts = associated_drafts_for_thread( + list_draft_metadata(self.api.as_ref(), &self.config.settings)?, + &thread_id, + ); + let bundle = GmailThreadNativeBundle { + mailbox, + thread, + associated_drafts, + }; let raw = serde_json::to_vec(&bundle).map_err(|error| { LocalityError::Io(format!("gmail thread native encode failed: {error}")) })?; @@ -399,6 +439,22 @@ 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 bundle = GmailDraftNativeBundle { + draft_id: 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: request.remote_id, + kind: "gmail_draft".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(), @@ -414,6 +470,14 @@ impl Connector for GmailConnector { } fn render(&self, entity: &NativeEntity) -> LocalityResult { + if entity.kind == "gmail_draft" { + let bundle = + serde_json::from_slice::(&entity.raw).map_err(|error| { + LocalityError::Io(format!("gmail draft native decode failed: {error}")) + })?; + return render_gmail_draft(&bundle).map(|rendered| rendered.document); + } + if entity.kind == "gmail_thread" { let bundle = serde_json::from_slice::(&entity.raw).map_err( |error| LocalityError::Io(format!("gmail thread native decode failed: {error}")), @@ -450,12 +514,30 @@ impl Connector for GmailConnector { }) } - fn check_concurrency(&self, _request: ApplyPlanRequest<'_>) -> LocalityResult<()> { + fn check_concurrency(&self, request: ApplyPlanRequest<'_>) -> LocalityResult<()> { + for precondition in request.remote_preconditions { + let Some(draft_id) = parse_draft_remote_id(&precondition.remote_id) else { + continue; + }; + let Some(expected) = precondition.remote_edited_at.as_deref() else { + continue; + }; + let current = self.api.get_draft_metadata(draft_id)?; + let current_version = draft_version(¤t); + if current_version != expected { + return Err(LocalityError::Guardrail(format!( + "Gmail draft `{draft_id}` changed remotely before apply (expected `{expected}`, found `{current_version}`)" + ))); + } + } Ok(()) } fn apply(&self, request: ApplyPlanRequest<'_>) -> LocalityResult { - let mut changed_remote_ids = Vec::new(); + self.check_concurrency(ApplyPlanRequest { ..request })?; + + let mut creates = Vec::new(); + let mut updates = BTreeMap::::new(); let mut effects = Vec::new(); for (index, operation) in request.plan.operations.iter().enumerate() { @@ -463,58 +545,148 @@ impl Connector for GmailConnector { 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")); - }; - if parent_id.as_str() != DRAFT_FOLDER_ID - || parent_kind.as_ref() != Some(&EntityKind::Directory) - || *parent_workspace - { - return Err(LocalityError::Unsupported("gmail create parent")); + match operation { + PushOperation::CreateEntity { + parent_id, + parent_kind, + parent_workspace, + title, + properties, + body, + source_path, + } => { + if parent_id.as_str() != DRAFT_FOLDER_ID + || parent_kind.as_ref() != Some(&EntityKind::Directory) + || *parent_workspace + { + return Err(LocalityError::Unsupported("gmail create parent")); + } + if !is_direct_draft_child(source_path) { + return Err(LocalityError::Unsupported("gmail draft source path")); + } + let draft = draft_from_push_create(title, properties, body)?; + let message_id = locality_message_id(request.push_id, &operation_id); + let mime = build_draft_mime_with_message_id(&draft, Some(&message_id))?; + creates.push(PreparedDraftCreate { + operation_id, + operation_index: index, + message_id, + request: GmailDraftCreateRequest { + message: GmailRawMessage { + raw: raw_message_base64url(&mime), + thread_id: draft.thread_id, + }, + }, + }); + } + PushOperation::UpdateEntityBody { entity_id, body } => { + let draft_id = required_draft_id(entity_id)?; + let pending = updates + .entry(entity_id.clone()) + .or_insert_with(|| PendingDraftUpdate::new(draft_id)); + if pending.body.replace(body.clone()).is_some() { + return Err(LocalityError::InvalidState(format!( + "Gmail push contains multiple body updates for `{}`", + entity_id.as_str() + ))); + } + effects.push(JournalApplyEffect::UpdatedEntityBody { + operation_id, + operation_index: index, + entity_id: entity_id.clone(), + }); + } + PushOperation::UpdateProperties { + entity_id, + properties, + } => { + let draft_id = required_draft_id(entity_id)?; + validate_draft_property_keys(properties)?; + let pending = updates + .entry(entity_id.clone()) + .or_insert_with(|| PendingDraftUpdate::new(draft_id)); + for (key, value) in properties { + if pending + .properties + .insert(key.clone(), value.clone()) + .is_some() + { + return Err(LocalityError::InvalidState(format!( + "Gmail push contains multiple updates for draft property `{key}`" + ))); + } + } + effects.push(JournalApplyEffect::UpdatedProperties { + operation_id, + operation_index: index, + entity_id: entity_id.clone(), + keys: properties.keys().cloned().collect(), + }); + } + _ => return Err(LocalityError::Unsupported("gmail push operation")), } - if !is_direct_draft_child(source_path) { - return Err(LocalityError::Unsupported("gmail draft source path")); + } + + let mut prepared_updates = Vec::new(); + for (entity_id, pending) in updates { + let current = self.api.get_draft_full(&pending.draft_id)?; + ensure_draft_is_safe_to_rewrite(¤t.message)?; + let mut document = gmail_draft_document_from_message(¤t.message); + apply_draft_property_updates(&mut document, &pending.properties)?; + if let Some(body) = pending.body { + document.body = body; } + let headers = current + .message + .payload + .as_ref() + .map(header_map) + .unwrap_or_default(); + let mime = build_draft_mime_with_message_id( + &document, + headers.get("message-id").map(String::as_str), + )?; + prepared_updates.push(( + entity_id, + pending.draft_id, + GmailDraftCreateRequest { + message: GmailRawMessage { + raw: raw_message_base64url(&mime), + thread_id: document.thread_id, + }, + }, + )); + } - 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 mut changed_remote_ids = Vec::new(); + for (entity_id, draft_id, update) in prepared_updates { + let updated = self.api.update_draft(&draft_id, update)?; + if updated.id != draft_id { + return Err(LocalityError::InvalidState(format!( + "Gmail draft update changed resource identity from `{draft_id}` to `{}`", + updated.id + ))); } + changed_remote_ids.push(entity_id); + } - let draft = draft_from_push_create(title, properties, body)?; - let mime = build_draft_mime_with_message_id(&draft, Some(&message_id))?; - let created = self.api.create_draft(GmailDraftCreateRequest { - message: GmailRawMessage { - raw: raw_message_base64url(&mime), - }, - })?; - let draft_message_id = RemoteId::new(created.message.id); - changed_remote_ids.push(draft_message_id.clone()); + for create in creates { + let created = match find_draft_by_message_id(self.api.as_ref(), &create.message_id)? { + Some(existing) => existing, + None => self.api.create_draft(create.request)?, + }; + let created_id = draft_remote_id(&created.id); + changed_remote_ids.push(created_id.clone()); effects.push(JournalApplyEffect::CreatedEntity { - operation_id, - operation_index: index, + operation_id: create.operation_id, + operation_index: create.operation_index, parent_id: RemoteId::new(DRAFT_FOLDER_ID), - entity_id: draft_message_id, + entity_id: created_id, }); } + effects.sort_by_key(journal_effect_operation_index); + Ok(ApplyPlanResult { changed_remote_ids, effects, @@ -526,17 +698,240 @@ impl Connector for GmailConnector { } } -fn find_sent_message_by_message_id( +#[derive(Debug)] +struct PreparedDraftCreate { + operation_id: PushOperationId, + operation_index: usize, + message_id: String, + request: GmailDraftCreateRequest, +} + +#[derive(Debug)] +struct PendingDraftUpdate { + draft_id: String, + body: Option, + properties: BTreeMap, +} + +impl PendingDraftUpdate { + fn new(draft_id: String) -> Self { + Self { + draft_id, + body: None, + properties: BTreeMap::new(), + } + } +} + +fn required_draft_id(entity_id: &RemoteId) -> LocalityResult { + parse_draft_remote_id(entity_id) + .map(str::to_string) + .ok_or(LocalityError::Unsupported( + "Gmail updates are supported only for files under draft/", + )) +} + +fn validate_draft_property_keys( + properties: &BTreeMap, +) -> LocalityResult<()> { + if let Some(key) = properties + .keys() + .find(|key| !matches!(key.as_str(), "title" | "to" | "cc" | "bcc" | "subject")) + { + return Err(gmail_draft_validation(format!( + "Gmail draft metadata `{key}` is read-only; edit only title/subject, to, cc, bcc, or the body" + ))); + } + Ok(()) +} + +fn apply_draft_property_updates( + draft: &mut GmailDraftDocument, + properties: &BTreeMap, +) -> LocalityResult<()> { + validate_draft_property_keys(properties)?; + for key in ["to", "cc", "bcc"] { + let Some(value) = properties.get(key) else { + continue; + }; + let recipients = property_recipients(value, key)?; + match key { + "to" => draft.to = recipients, + "cc" => draft.cc = recipients, + "bcc" => draft.bcc = recipients, + _ => unreachable!(), + } + } + + let title = properties + .get("title") + .map(|value| property_string(value, "title")) + .transpose()?; + let subject = properties + .get("subject") + .map(|value| property_string(value, "subject")) + .transpose()?; + if let (Some(title), Some(subject)) = (&title, &subject) + && title != subject + { + return Err(gmail_draft_validation( + "Gmail draft `title` and `subject` must match when both are edited".to_string(), + )); + } + if let Some(subject) = subject.or(title) { + draft.subject = subject; + } + Ok(()) +} + +fn property_recipients(value: &PropertyValue, key: &str) -> LocalityResult> { + match value { + PropertyValue::List(values) => Ok(values.clone()), + PropertyValue::String(value) => Ok(vec![value.clone()]), + PropertyValue::Null => Ok(Vec::new()), + _ => Err(gmail_draft_validation(format!( + "Gmail draft `{key}` must be a string or list of strings" + ))), + } +} + +fn property_string(value: &PropertyValue, key: &str) -> LocalityResult { + match value { + PropertyValue::String(value) => Ok(value.clone()), + PropertyValue::Null => Ok(String::new()), + _ => Err(gmail_draft_validation(format!( + "Gmail draft `{key}` must be a string" + ))), + } +} + +fn gmail_draft_validation(message: String) -> LocalityError { + LocalityError::Validation(vec![ValidationIssue::new( + "gmail_draft_update_invalid", + PathBuf::new(), + Some(1), + message, + Some( + "restore generated Gmail metadata and edit only draft recipients, subject, or body" + .to_string(), + ), + )]) +} + +fn ensure_draft_is_safe_to_rewrite(message: &GmailMessage) -> LocalityResult<()> { + let Some(payload) = message.payload.as_ref() else { + return Err(unsupported_draft_rewrite()); + }; + if !is_simple_text_plain_draft_payload(payload) { + return Err(unsupported_draft_rewrite()); + } + Ok(()) +} + +fn is_simple_text_plain_draft_payload(payload: &GmailMessagePart) -> bool { + if !payload + .mime_type + .as_deref() + .is_some_and(|mime_type| mime_type.eq_ignore_ascii_case("text/plain")) + || !payload.parts.is_empty() + || payload + .filename + .as_deref() + .is_some_and(|filename| !filename.trim().is_empty()) + || payload + .body + .as_ref() + .and_then(|body| body.attachment_id.as_deref()) + .is_some() + { + return false; + } + + payload.headers.iter().all(|header| { + matches!( + header.name.to_ascii_lowercase().as_str(), + "to" | "cc" + | "bcc" + | "subject" + | "in-reply-to" + | "references" + | "message-id" + | "mime-version" + | "content-type" + | "content-transfer-encoding" + | "from" + | "date" + ) + }) +} + +fn unsupported_draft_rewrite() -> LocalityError { + LocalityError::Unsupported( + "Gmail draft updates in Locality V1 support only simple text/plain drafts without attachments, HTML, multipart content, or custom MIME headers; edit this draft in Gmail instead", + ) +} + +fn journal_effect_operation_index(effect: &JournalApplyEffect) -> usize { + match effect { + JournalApplyEffect::UpdatedBlock { + operation_index, .. + } + | JournalApplyEffect::CreatedBlock { + operation_index, .. + } + | JournalApplyEffect::MovedBlock { + operation_index, .. + } + | JournalApplyEffect::ArchivedBlock { + operation_index, .. + } + | JournalApplyEffect::ArchivedEntity { + operation_index, .. + } + | JournalApplyEffect::UpdatedEntityBody { + operation_index, .. + } + | JournalApplyEffect::UpdatedProperties { + operation_index, .. + } + | JournalApplyEffect::MovedEntity { + operation_index, .. + } + | JournalApplyEffect::CreatedEntity { + operation_index, .. + } => *operation_index, + } +} + +fn find_draft_by_message_id( api: &dyn GmailApi, message_id: &str, -) -> LocalityResult> { +) -> LocalityResult> { let query = format!("rfc822msgid:<{message_id}>"); - let list = api.list_messages("SENT", 10, None, Some(&query))?; + let list = api.list_messages("DRAFT", 10, None, Some(&query))?; let Some(message_ref) = list.messages.first() else { return Ok(None); }; - - api.get_message_metadata(&message_ref.id).map(Some) + let mut page_token = None; + let mut seen_page_tokens = BTreeSet::new(); + loop { + let page = api.list_drafts(GMAIL_PAGE_SIZE, page_token.as_deref())?; + for draft_ref in page.drafts { + let draft = api.get_draft_metadata(&draft_ref.id)?; + if draft.message.id == message_ref.id { + return Ok(Some(draft)); + } + } + let Some(next) = page.next_page_token else { + return Ok(None); + }; + if !seen_page_tokens.insert(next.clone()) { + return Err(LocalityError::InvalidState(format!( + "gmail pagination returned repeated page token `{next}` while reconciling draft create" + ))); + } + page_token = Some(next); + } } fn locality_message_id(push_id: &PushId, operation_id: &PushOperationId) -> String { @@ -625,6 +1020,13 @@ fn gmail_thread_metadata_json(thread: &GmailThread, mailbox: &str) -> String { metadata_json(thread, gmail_thread_search_metadata(thread, mailbox)) } +fn gmail_draft_metadata_json(draft: &GmailDraft) -> String { + metadata_json( + draft, + gmail_message_search_metadata(&draft.message, "draft", draft.message.thread_id.as_deref()), + ) +} + fn metadata_json(value: &T, search_metadata: SearchMetadata) -> String where T: Serialize, @@ -753,18 +1155,101 @@ fn list_thread_entries( label_id: &str, mailbox: &str, parent_path: &Path, + drafts: &[GmailDraft], ) -> LocalityResult> { let threads = list_thread_refs(api, settings, label_id)?; let mut entries = Vec::new(); for thread_ref in threads { let thread = api.get_thread_metadata(&thread_ref.id)?; if thread_starts_in_date_window(settings, &thread) { - entries.push(thread_entry(mount_id, parent_path, mailbox, thread)); + let associated_drafts = associated_drafts_for_thread(drafts.to_vec(), &thread.id); + entries.push(thread_entry( + mount_id, + parent_path, + mailbox, + thread, + associated_drafts, + )); } } Ok(entries) } +fn list_draft_entries( + api: &dyn GmailApi, + settings: &GmailMountSettings, + mount_id: &MountId, + parent_path: &Path, +) -> LocalityResult> { + list_draft_metadata(api, settings).map(|drafts| draft_entries(mount_id, parent_path, drafts)) +} + +fn draft_entries( + mount_id: &MountId, + parent_path: &Path, + drafts: Vec, +) -> Vec { + drafts + .into_iter() + .map(|draft| draft_entry(mount_id, parent_path, draft)) + .collect() +} + +fn list_draft_metadata( + api: &dyn GmailApi, + settings: &GmailMountSettings, +) -> LocalityResult> { + let paginate_all = settings.gmail.date_window.is_some(); + let mut page_token = None; + let mut seen_page_tokens = BTreeSet::new(); + let mut drafts = Vec::new(); + loop { + let page = api.list_drafts(GMAIL_PAGE_SIZE, page_token.as_deref())?; + for draft_ref in page.drafts { + let draft = api.get_draft_metadata(&draft_ref.id)?; + if message_in_date_window(settings, &draft.message) { + drafts.push(draft); + } + } + if !paginate_all { + break; + } + let Some(next) = page.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(drafts) +} + +fn associated_drafts_for_thread( + drafts: Vec, + thread_id: &str, +) -> Vec { + let mut references = drafts + .into_iter() + .filter(|draft| draft.message.thread_id.as_deref() == Some(thread_id)) + .map(|draft| { + let title = message_subject(&draft.message); + GmailThreadDraftReference { + path: Path::new("draft") + .join(draft_filename(&draft, &title)) + .display() + .to_string(), + message_id: draft.message.id, + draft_id: draft.id, + } + }) + .collect::>(); + references.sort_by(|left, right| left.path.cmp(&right.path)); + references +} + fn list_message_refs( api: &dyn GmailApi, settings: &GmailMountSettings, @@ -871,6 +1356,30 @@ fn message_entry( } } +fn draft_entry(mount_id: &MountId, parent_path: &Path, draft: GmailDraft) -> TreeEntry { + let title = message_subject(&draft.message); + let version = draft_version(&draft); + let path = parent_path.join(draft_filename(&draft, &title)); + let bundle = GmailDraftNativeBundle { + draft_id: draft.id.clone(), + message: draft.message.clone(), + }; + let stub_frontmatter = render_gmail_draft(&bundle) + .ok() + .map(|rendered| rendered.document.frontmatter); + TreeEntry { + mount_id: mount_id.clone(), + remote_id: draft_remote_id(&draft.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, @@ -909,20 +1418,22 @@ fn thread_entry( parent_path: &Path, mailbox: &str, thread: GmailThread, + associated_drafts: Vec, ) -> TreeEntry { let title = thread .messages .first() .map(message_subject) .unwrap_or_else(|| "(no subject)".to_string()); - let version = thread_remote_version(&thread); let path = parent_path .join(thread_directory_name(&thread, &title)) .join("page.md"); let bundle = GmailThreadNativeBundle { mailbox: mailbox.to_string(), thread: thread.clone(), + associated_drafts, }; + let version = thread_bundle_remote_version(&bundle); let stub_frontmatter = render_gmail_thread(&bundle) .ok() .map(|rendered| rendered.document.frontmatter); @@ -940,6 +1451,10 @@ fn thread_entry( } } +fn draft_version(draft: &GmailDraft) -> String { + draft_remote_version(&draft.id, &draft.message) +} + fn message_subject(message: &GmailMessage) -> String { message .payload @@ -960,6 +1475,16 @@ fn message_filename(message: &GmailMessage, title: &str) -> String { ) } +fn draft_filename(draft: &GmailDraft, title: &str) -> String { + let date = draft.message.internal_date.as_deref().unwrap_or("unknown"); + format!( + "{}-{}-{}.md", + safe_slug(date), + safe_slug(title), + safe_slug(&draft.id) + ) +} + fn thread_directory_name(thread: &GmailThread, title: &str) -> String { let date = thread .messages @@ -987,6 +1512,22 @@ fn thread_starts_in_date_window(settings: &GmailMountSettings, thread: &GmailThr start_date >= after && start_date < before } +fn message_in_date_window(settings: &GmailMountSettings, message: &GmailMessage) -> bool { + let Some(window) = settings.gmail.date_window.as_ref() else { + return true; + }; + let Some(date) = message + .internal_date + .as_deref() + .and_then(gmail_internal_date_utc_key) + else { + return true; + }; + let after = gmail_search_date_key(window.after().as_str()); + let before = gmail_search_date_key(window.before().as_str()); + date >= after && date < before +} + fn thread_start_utc_date_key(thread: &GmailThread) -> Option { thread .messages @@ -1087,6 +1628,9 @@ struct RawDraftFrontmatter { struct RawDraftGmailFrontmatter { attachment: Option, attachments: Option, + thread_id: Option, + in_reply_to: Option, + references: Option>, } #[derive(Debug, Deserialize)] @@ -1113,6 +1657,7 @@ fn parse_gmail_draft_document(document: &CanonicalDocument) -> LocalityResult LocalityResult, + object: &str, + key: &str, +) -> Option { + match properties.get(object) { + Some(PropertyValue::Object(values)) => string_property(values, key), + _ => None, + } +} + +fn nested_list_property( + properties: &BTreeMap, + object: &str, + key: &str, +) -> Vec { + match properties.get(object) { + Some(PropertyValue::Object(values)) => recipients_property(values, key), + _ => Vec::new(), + } +} + fn draft_properties_have_attachments(properties: &BTreeMap) -> bool { properties.contains_key("attachments") || properties.contains_key("attachment") @@ -1192,6 +1767,9 @@ struct DraftNative { bcc: Vec, subject: String, body: String, + thread_id: Option, + in_reply_to: Option, + references: Vec, } impl From for DraftNative { @@ -1202,6 +1780,9 @@ impl From for DraftNative { bcc: value.bcc, subject: value.subject, body: value.body, + thread_id: value.thread_id, + in_reply_to: value.in_reply_to, + references: value.references, } } } @@ -1226,15 +1807,19 @@ mod tests { use super::{GmailConfig, GmailConnector}; use crate::client::GmailApi; use crate::dto::{ - GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList, + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailMessage, GmailMessageList, GmailMessagePartBody, GmailMessageRef, GmailThread, GmailThreadList, }; - use crate::settings::GmailMountSettings; + use crate::settings::{GmailMountSettings, GmailProjectionView}; #[test] fn enumerate_projects_three_folders_and_recent_inbox_sent_draft_messages() { let api = Arc::new(FakeGmailApi::default()); - let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let settings = GmailMountSettings::default().with_view(GmailProjectionView::Messages); + let connector = GmailConnector::with_api( + GmailConfig::new("token").with_settings(settings), + api.clone(), + ); let entries = connector .enumerate(EnumerateRequest { @@ -1263,7 +1848,7 @@ mod tests { assert!(entries.iter().any(|entry| entry.path.starts_with("draft/"))); assert_eq!( api.calls.lock().expect("calls").list_max_results, - vec![100, 100, 100] + vec![100, 100] ); } @@ -1297,7 +1882,8 @@ mod tests { } let settings = crate::settings::GmailMountSettings::with_date_window("2026-07-01", "2026-07-15") - .expect("date window"); + .expect("date window") + .with_view(GmailProjectionView::Messages); let connector = GmailConnector::with_api( GmailConfig::new("token").with_settings(settings), api.clone(), @@ -1327,19 +1913,22 @@ 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.list_page_tokens, - vec![None, Some("next-inbox".to_string()), None, None] + vec![None, Some("next-inbox".to_string()), None] ); } #[test] fn enumerate_without_date_window_keeps_recent_100_single_page_behavior() { let api = Arc::new(FakeGmailApi::default()); - let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let settings = GmailMountSettings::default().with_view(GmailProjectionView::Messages); + let connector = GmailConnector::with_api( + GmailConfig::new("token").with_settings(settings), + api.clone(), + ); connector .enumerate(EnumerateRequest { @@ -1349,8 +1938,8 @@ 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.list_page_tokens, vec![None, None]); assert!(calls.list_queries.is_empty()); } @@ -1383,8 +1972,9 @@ mod tests { }, ); } - let settings = - GmailMountSettings::with_date_window("2026-07-01", "2026-07-15").expect("settings"); + let settings = GmailMountSettings::with_date_window("2026-07-01", "2026-07-15") + .expect("settings") + .with_view(GmailProjectionView::Messages); let connector = GmailConnector::with_api( GmailConfig::new("token").with_settings(settings), api.clone(), @@ -1433,6 +2023,37 @@ mod tests { ); } + #[test] + fn associated_thread_drafts_use_canonical_root_paths_and_stable_ids() { + let mut matching_message = message_fixture("draft-message-7"); + matching_message.thread_id = Some("thread-1".to_string()); + let mut unrelated_message = message_fixture("draft-message-8"); + unrelated_message.thread_id = Some("thread-2".to_string()); + + let references = super::associated_drafts_for_thread( + vec![ + GmailDraft { + id: "draft-7".to_string(), + message: matching_message, + }, + GmailDraft { + id: "draft-8".to_string(), + message: unrelated_message, + }, + ], + "thread-1", + ); + + assert_eq!( + references, + vec![crate::render::GmailThreadDraftReference { + draft_id: "draft-7".to_string(), + message_id: "draft-message-7".to_string(), + path: "draft/1720900000000-hello-draft-7.md".to_string(), + }] + ); + } + #[test] fn list_children_for_draft_folder_returns_remote_drafts() { let api = Arc::new(FakeGmailApi::default()); @@ -1693,8 +2314,9 @@ mod tests { }, ); } - let settings = - GmailMountSettings::with_date_window("2026-07-01", "2026-07-15").expect("settings"); + let settings = GmailMountSettings::with_date_window("2026-07-01", "2026-07-15") + .expect("settings") + .with_view(GmailProjectionView::Messages); let connector = GmailConnector::with_api( GmailConfig::new("token").with_settings(settings), api.clone(), @@ -1856,11 +2478,10 @@ 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); - assert!(calls.sent_drafts.is_empty()); let raw = calls.created_draft_raw.last().expect("created draft raw"); let mime = String::from_utf8( URL_SAFE_NO_PAD @@ -1876,23 +2497,16 @@ mod tests { } #[test] - fn apply_create_entity_recovers_existing_sent_message_by_message_id_without_duplicate() { + fn apply_create_entity_creates_thread_reply_draft_without_sending() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); - let push_id = PushId("push-1".to_string()); - let operation_id = PushOperationId("op-1".to_string()); - let message_id = super::locality_message_id(&push_id, &operation_id); - api.calls.lock().expect("calls").sent_search_results.insert( - format!("rfc822msgid:<{message_id}>"), - "sent-msg-previous".to_string(), - ); let plan = PushPlan::new( vec![RemoteId::new("gmail-folder:draft")], vec![PushOperation::CreateEntity { parent_id: RemoteId::new("gmail-folder:draft"), parent_kind: Some(EntityKind::Directory), parent_workspace: false, - title: "Hello".to_string(), + title: "Re: Quarterly update".to_string(), properties: std::collections::BTreeMap::from([ ( "to".to_string(), @@ -1900,113 +2514,371 @@ mod tests { ), ( "subject".to_string(), - PropertyValue::String("Explicit subject".to_string()), + PropertyValue::String("Re: Quarterly update".to_string()), + ), + ( + "gmail".to_string(), + PropertyValue::Object(std::collections::BTreeMap::from([ + ( + "thread_id".to_string(), + PropertyValue::String("thread-1".to_string()), + ), + ( + "in_reply_to".to_string(), + PropertyValue::String("".to_string()), + ), + ( + "references".to_string(), + PropertyValue::List(vec![ + "".to_string(), + "".to_string(), + ]), + ), + ])), ), ]), - body: "Body\n".to_string(), - source_path: "draft/hello.md".into(), + body: "Thanks for the update.\n".to_string(), + source_path: "draft/reply.md".into(), }], ); let result = connector .apply(locality_connector::ApplyPlanRequest { - push_id: &push_id, + push_id: &PushId("push-reply".to_string()), mount_id: &MountId::new("gmail-main"), plan: &plan, - operation_ids: std::slice::from_ref(&operation_id), + operation_ids: &[PushOperationId("op-reply".to_string())], remote_preconditions: &[] as &[RemotePrecondition], local_root: None, }) - .expect("apply"); + .expect("apply reply draft"); assert_eq!( result.changed_remote_ids, - vec![RemoteId::new("sent-msg-previous")] + vec![RemoteId::new("gmail-draft:draft-1")] ); let calls = api.calls.lock().expect("calls"); - assert_eq!(calls.created_drafts, 0); - assert!(calls.sent_drafts.is_empty()); + assert_eq!(calls.created_drafts, 1); assert_eq!( - calls.list_queries, - vec![format!("rfc822msgid:<{message_id}>")] + calls.created_draft_thread_ids, + vec![Some("thread-1".to_string())] ); + let mime = String::from_utf8( + URL_SAFE_NO_PAD + .decode( + calls + .created_draft_raw + .last() + .expect("created reply raw") + .as_bytes(), + ) + .expect("decode reply mime"), + ) + .expect("utf8 reply mime"); + assert!(mime.contains("In-Reply-To: \r\n")); + assert!(mime.contains("References: \r\n")); + assert!(mime.ends_with("\r\n\r\nThanks for the update.\r\n")); } #[test] - fn apply_create_entity_does_not_send_when_send_endpoint_would_fail() { + fn apply_updates_existing_draft_body_and_subject_without_sending() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); - let push_id = PushId("push-1".to_string()); - let operation_id = PushOperationId("op-1".to_string()); - let message_id = super::locality_message_id(&push_id, &operation_id); - { - let mut calls = api.calls.lock().expect("calls"); - calls.send_error = Some(LocalityError::Io( - "gmail draft send response decode failed".to_string(), - )); - calls.sent_search_results_after_send.insert( - format!("rfc822msgid:<{message_id}>"), - "sent-msg-recovered".to_string(), - ); - } + let entity_id = RemoteId::new("gmail-draft:draft-1"); let plan = PushPlan::new( - vec![RemoteId::new("gmail-folder:draft")], - vec![PushOperation::CreateEntity { - parent_id: RemoteId::new("gmail-folder:draft"), - parent_kind: Some(EntityKind::Directory), - parent_workspace: false, - title: "Hello".to_string(), - properties: std::collections::BTreeMap::from([ - ( - "to".to_string(), - PropertyValue::List(vec!["ann@example.com".to_string()]), - ), - ( + vec![entity_id.clone()], + vec![ + PushOperation::UpdateProperties { + entity_id: entity_id.clone(), + properties: std::collections::BTreeMap::from([( "subject".to_string(), - PropertyValue::String("Explicit subject".to_string()), - ), - ]), - body: "Body\n".to_string(), - source_path: "draft/hello.md".into(), - }], + PropertyValue::String("Updated subject".to_string()), + )]), + }, + PushOperation::UpdateEntityBody { + entity_id: entity_id.clone(), + body: "Updated body.\n".to_string(), + }, + ], ); let result = connector .apply(locality_connector::ApplyPlanRequest { - push_id: &push_id, + push_id: &PushId("push-update".to_string()), mount_id: &MountId::new("gmail-main"), plan: &plan, - operation_ids: std::slice::from_ref(&operation_id), + operation_ids: &[ + PushOperationId("op-properties".to_string()), + PushOperationId("op-body".to_string()), + ], remote_preconditions: &[] as &[RemotePrecondition], local_root: None, }) - .expect("apply"); + .expect("update draft"); - assert_eq!( - result.changed_remote_ids, - vec![RemoteId::new("draft-message-1")] - ); + assert_eq!(result.changed_remote_ids, vec![entity_id]); let calls = api.calls.lock().expect("calls"); - assert_eq!(calls.created_drafts, 1); - assert!(calls.sent_drafts.is_empty()); + assert_eq!(calls.created_drafts, 0); + assert_eq!(calls.updated_draft_raw.len(), 1); + let (draft_id, raw) = &calls.updated_draft_raw[0]; + assert_eq!(draft_id, "draft-1"); + let mime = String::from_utf8( + URL_SAFE_NO_PAD + .decode(raw.as_bytes()) + .expect("decode updated mime"), + ) + .expect("utf8 updated mime"); + assert!(mime.contains("To: me@example.com\r\n")); + assert!(mime.contains("Subject: Updated subject\r\n")); + assert!(mime.ends_with("\r\n\r\nUpdated body.\r\n")); assert_eq!( - calls.list_queries, - vec![format!("rfc822msgid:<{message_id}>")] + calls.updated_draft_thread_ids, + vec![Some("draft-msg-1-thread".to_string())] ); } #[test] - fn apply_create_entity_does_not_depend_on_sent_lookup() { + fn apply_rejects_remote_draft_drift_before_update() { let api = Arc::new(FakeGmailApi::default()); let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); - { - let mut calls = api.calls.lock().expect("calls"); - calls.send_error = Some(LocalityError::Io( - "gmail draft send response decode failed".to_string(), - )); - calls.sent_search_error_after_send = - Some(LocalityError::Io("sent search timed out".to_string())); - } + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let plan = PushPlan::new( + vec![entity_id.clone()], + vec![PushOperation::UpdateEntityBody { + entity_id: entity_id.clone(), + body: "Local edit.\n".to_string(), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-stale".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-stale".to_string())], + remote_preconditions: &[RemotePrecondition { + remote_id: entity_id, + remote_edited_at: Some("gmail-draft:draft-1:stale".to_string()), + }], + local_root: None, + }) + .expect_err("stale remote draft must fail"); + + assert!(matches!(error, LocalityError::Guardrail(_))); + assert!( + api.calls + .lock() + .expect("calls") + .updated_draft_raw + .is_empty() + ); + } + + #[test] + fn apply_rejects_changes_to_draft_thread_identity() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let plan = PushPlan::new( + vec![entity_id.clone()], + vec![PushOperation::UpdateProperties { + entity_id, + properties: std::collections::BTreeMap::from([( + "gmail".to_string(), + PropertyValue::Object(std::collections::BTreeMap::from([( + "thread_id".to_string(), + PropertyValue::String("different-thread".to_string()), + )])), + )]), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-identity".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-identity".to_string())], + remote_preconditions: &[], + local_root: None, + }) + .expect_err("thread identity edits must fail"); + + assert!(matches!(error, LocalityError::Validation(_))); + assert!( + api.calls + .lock() + .expect("calls") + .updated_draft_raw + .is_empty() + ); + } + + #[test] + fn apply_rejects_editing_remote_draft_with_attachments() { + let api = Arc::new(FakeGmailApi::default()); + let message = serde_json::from_value(serde_json::json!({ + "id": "draft-msg-1", + "threadId": "thread-1", + "labelIds": ["DRAFT"], + "internalDate": "1720900000000", + "payload": { + "mimeType": "multipart/mixed", + "headers": [ + { "name": "To", "value": "me@example.com" }, + { "name": "Subject", "value": "Attachment draft" } + ], + "parts": [ + { "mimeType": "text/plain", "body": { "data": "Qm9keQo" } }, + { + "partId": "2", + "filename": "invoice.pdf", + "mimeType": "application/pdf", + "body": { "attachmentId": "attachment-1", "size": 10 } + } + ] + } + })) + .expect("draft message"); + api.calls.lock().expect("calls").draft_full = Some(GmailDraft { + id: "draft-1".to_string(), + message, + }); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let plan = PushPlan::new( + vec![entity_id.clone()], + vec![PushOperation::UpdateEntityBody { + entity_id, + body: "Would discard attachment.\n".to_string(), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId("push-attachment".to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-attachment".to_string())], + remote_preconditions: &[], + local_root: None, + }) + .expect_err("attachment draft update must fail"); + + assert!(matches!(error, LocalityError::Unsupported(_))); + assert!( + api.calls + .lock() + .expect("calls") + .updated_draft_raw + .is_empty() + ); + } + + #[test] + fn apply_rejects_editing_html_only_remote_draft_without_rewriting_it() { + let api = Arc::new(FakeGmailApi::default()); + let message = serde_json::from_value(serde_json::json!({ + "id": "draft-msg-html", + "threadId": "thread-1", + "labelIds": ["DRAFT"], + "internalDate": "1720900000000", + "payload": { + "mimeType": "text/html", + "headers": [ + { "name": "To", "value": "me@example.com" }, + { "name": "Subject", "value": "HTML draft" } + ], + "body": { "data": "PHA-SGVsbG88L3A-" } + } + })) + .expect("HTML draft message"); + assert_draft_update_rejected_without_rewrite(api, message, "push-html"); + } + + #[test] + fn apply_rejects_multipart_remote_draft_without_attachment_without_rewriting_it() { + let api = Arc::new(FakeGmailApi::default()); + let message = serde_json::from_value(serde_json::json!({ + "id": "draft-msg-multipart", + "threadId": "thread-1", + "labelIds": ["DRAFT"], + "internalDate": "1720900000000", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { "name": "To", "value": "me@example.com" }, + { "name": "Subject", "value": "Multipart draft" } + ], + "parts": [ + { "mimeType": "text/plain", "body": { "data": "Qm9keQo" } }, + { "mimeType": "text/html", "body": { "data": "PHA-SGVsbG88L3A-" } } + ] + } + })) + .expect("multipart draft message"); + assert_draft_update_rejected_without_rewrite(api, message, "push-multipart"); + } + + fn assert_draft_update_rejected_without_rewrite( + api: Arc, + message: GmailMessage, + push_id: &str, + ) { + api.calls.lock().expect("calls").draft_full = Some(GmailDraft { + id: "draft-1".to_string(), + message, + }); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let entity_id = RemoteId::new("gmail-draft:draft-1"); + let plan = PushPlan::new( + vec![entity_id.clone()], + vec![PushOperation::UpdateEntityBody { + entity_id, + body: "Would flatten the draft.\n".to_string(), + }], + ); + + let error = connector + .apply(locality_connector::ApplyPlanRequest { + push_id: &PushId(push_id.to_string()), + mount_id: &MountId::new("gmail-main"), + plan: &plan, + operation_ids: &[PushOperationId("op-unsafe-format".to_string())], + remote_preconditions: &[], + local_root: None, + }) + .expect_err("unsafe draft rewrite must fail"); + + assert!(matches!( + error, + LocalityError::Unsupported(message) + if message.contains("simple text/plain drafts") && message.contains("edit this draft in Gmail") + )); + assert!( + api.calls + .lock() + .expect("calls") + .updated_draft_raw + .is_empty() + ); + } + + #[test] + fn apply_create_entity_recovers_existing_draft_by_message_id_without_duplicate() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let push_id = PushId("push-1".to_string()); + let operation_id = PushOperationId("op-1".to_string()); + let message_id = super::locality_message_id(&push_id, &operation_id); + api.calls + .lock() + .expect("calls") + .message_search_results + .insert( + format!("rfc822msgid:<{message_id}>"), + "draft-msg-1".to_string(), + ); let plan = PushPlan::new( vec![RemoteId::new("gmail-folder:draft")], vec![PushOperation::CreateEntity { @@ -2031,22 +2903,25 @@ mod tests { let result = connector .apply(locality_connector::ApplyPlanRequest { - push_id: &PushId("push-1".to_string()), + push_id: &push_id, mount_id: &MountId::new("gmail-main"), plan: &plan, - operation_ids: &[PushOperationId("op-1".to_string())], + operation_ids: std::slice::from_ref(&operation_id), remote_preconditions: &[] as &[RemotePrecondition], local_root: None, }) - .expect("draft creation should not send or query sent mail"); + .expect("apply"); 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); - assert!(calls.sent_drafts.is_empty()); + assert_eq!(calls.created_drafts, 0); + assert_eq!( + calls.list_queries, + vec![format!("rfc822msgid:<{message_id}>")] + ); } #[test] @@ -2089,7 +2964,6 @@ mod tests { assert!(matches!(error, LocalityError::Unsupported(_))); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 0); - assert!(calls.sent_drafts.is_empty()); } #[test] @@ -2139,7 +3013,6 @@ mod tests { assert!(matches!(error, LocalityError::Unsupported(_))); let calls = api.calls.lock().expect("calls"); assert_eq!(calls.created_drafts, 0); - assert!(calls.sent_drafts.is_empty()); } #[test] @@ -2198,14 +3071,14 @@ mod tests { thread_metadata: std::collections::BTreeMap, list_page_tokens: Vec>, panic_after_list_calls: Option, - sent_search_results: std::collections::BTreeMap, - sent_search_results_after_send: std::collections::BTreeMap, - send_error: Option, - sent_search_error_after_send: Option, + message_search_results: std::collections::BTreeMap, message_labels: std::collections::BTreeMap>, created_drafts: usize, created_draft_raw: Vec, - sent_drafts: Vec, + created_draft_thread_ids: Vec>, + updated_draft_raw: Vec<(String, String)>, + updated_draft_thread_ids: Vec>, + draft_full: Option, } impl GmailApi for FakeGmailApi { @@ -2235,31 +3108,11 @@ mod tests { { return Ok(page); } - if let Some(sent_message_id) = calls.sent_search_results.get(query.unwrap_or_default()) - { + if let Some(message_id) = calls.message_search_results.get(query.unwrap_or_default()) { return Ok(GmailMessageList { messages: vec![GmailMessageRef { - id: sent_message_id.clone(), - thread_id: Some(format!("{sent_message_id}-thread")), - }], - next_page_token: None, - result_size_estimate: Some(1), - }); - } - if !calls.sent_drafts.is_empty() - && let Some(error) = calls.sent_search_error_after_send.clone() - { - return Err(error); - } - if !calls.sent_drafts.is_empty() - && let Some(sent_message_id) = calls - .sent_search_results_after_send - .get(query.unwrap_or_default()) - { - return Ok(GmailMessageList { - messages: vec![GmailMessageRef { - id: sent_message_id.clone(), - thread_id: Some(format!("{sent_message_id}-thread")), + id: message_id.clone(), + thread_id: Some(format!("{message_id}-thread")), }], next_page_token: None, result_size_estimate: Some(1), @@ -2372,6 +3225,35 @@ mod tests { Ok(thread_fixture(thread_id)) } + fn list_drafts( + &self, + _max_results: u32, + _page_token: Option<&str>, + ) -> locality_core::LocalityResult { + Ok(GmailDraftList { + drafts: vec![GmailDraft { + id: "draft-1".to_string(), + message: message_fixture("draft-msg-1"), + }], + next_page_token: None, + result_size_estimate: Some(1), + }) + } + + fn get_draft_metadata(&self, draft_id: &str) -> locality_core::LocalityResult { + Ok(GmailDraft { + id: draft_id.to_string(), + message: message_fixture("draft-msg-1"), + }) + } + + fn get_draft_full(&self, draft_id: &str) -> locality_core::LocalityResult { + if let Some(draft) = self.calls.lock().expect("calls").draft_full.clone() { + return Ok(draft); + } + self.get_draft_metadata(draft_id) + } + fn get_attachment( &self, _message_id: &str, @@ -2387,22 +3269,31 @@ mod tests { let mut calls = self.calls.lock().expect("calls"); calls.created_drafts += 1; calls.created_draft_raw.push(request.message.raw); + calls + .created_draft_thread_ids + .push(request.message.thread_id); Ok(GmailDraft { id: "draft-1".to_string(), message: message_fixture("draft-message-1"), }) } - fn send_draft( + fn update_draft( &self, - request: GmailDraftSendRequest, - ) -> locality_core::LocalityResult { + draft_id: &str, + request: GmailDraftCreateRequest, + ) -> locality_core::LocalityResult { let mut calls = self.calls.lock().expect("calls"); - calls.sent_drafts.push(request.id); - if let Some(error) = calls.send_error.clone() { - return Err(error); - } - Ok(message_fixture("sent-msg-1")) + calls + .updated_draft_thread_ids + .push(request.message.thread_id); + calls + .updated_draft_raw + .push((draft_id.to_string(), request.message.raw)); + Ok(GmailDraft { + id: draft_id.to_string(), + message: message_fixture("draft-msg-1"), + }) } } diff --git a/crates/locality-gmail/src/dto.rs b/crates/locality-gmail/src/dto.rs index 64e21d46..b1b59bde 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 { @@ -89,14 +98,11 @@ pub struct GmailDraftCreateRequest { pub message: GmailRawMessage, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct GmailDraftSendRequest { - pub id: String, -} - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GmailRawMessage { pub raw: String, + #[serde(rename = "threadId", skip_serializing_if = "Option::is_none")] + pub thread_id: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/locality-gmail/src/oauth.rs b/crates/locality-gmail/src/oauth.rs index 5716310e..f200370e 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, diff --git a/crates/locality-gmail/src/render.rs b/crates/locality-gmail/src/render.rs index c414d707..f7e47974 100644 --- a/crates/locality-gmail/src/render.rs +++ b/crates/locality-gmail/src/render.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use base64::Engine; use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; use locality_core::model::{CanonicalDocument, RemoteId}; @@ -20,6 +22,21 @@ pub struct GmailNativeBundle { pub struct GmailThreadNativeBundle { pub mailbox: String, pub thread: GmailThread, + #[serde(default)] + pub associated_drafts: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GmailThreadDraftReference { + pub draft_id: String, + pub message_id: String, + pub path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GmailDraftNativeBundle { + pub draft_id: String, + pub message: GmailMessage, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -43,6 +60,9 @@ pub struct GmailDraftDocument { pub bcc: Vec, pub subject: String, pub body: String, + pub thread_id: Option, + pub in_reply_to: Option, + pub references: Vec, } pub fn render_gmail_message(bundle: &GmailNativeBundle) -> LocalityResult { @@ -63,8 +83,13 @@ fn render_gmail_message_with_entity_id( String::new() } }); - let frontmatter = - message_frontmatter_with_attachment_state(bundle, Some(&attachment_specs), &entity_id); + let frontmatter = message_frontmatter_with_attachment_state( + bundle, + Some(&attachment_specs), + &entity_id, + None, + None, + ); let document = CanonicalDocument::new(frontmatter.clone(), body.clone()); let native_block_ids = synthetic_body_block_ids(&bundle.message.id, &body); let shadow = ShadowDocument::from_synced_body(entity_id, body, 1, native_block_ids) @@ -78,6 +103,43 @@ fn render_gmail_message_with_entity_id( }) } +pub fn draft_remote_id(draft_id: &str) -> RemoteId { + RemoteId::new(format!("gmail-draft:{draft_id}")) +} + +pub fn parse_draft_remote_id(remote_id: &RemoteId) -> Option<&str> { + remote_id.as_str().strip_prefix("gmail-draft:") +} + +pub fn render_gmail_draft(bundle: &GmailDraftNativeBundle) -> LocalityResult { + let entity_id = draft_remote_id(&bundle.draft_id); + let message_bundle = GmailNativeBundle { + mailbox: "draft".to_string(), + message: bundle.message.clone(), + }; + let attachment_specs = collect_attachment_specs(&bundle.message); + let body = message_body(&bundle.message).unwrap_or_default(); + let version = draft_remote_version(&bundle.draft_id, &bundle.message); + let frontmatter = message_frontmatter_with_attachment_state( + &message_bundle, + Some(&attachment_specs), + &entity_id, + Some(&bundle.draft_id), + Some(&version), + ); + let document = CanonicalDocument::new(frontmatter.clone(), body.clone()); + let native_block_ids = synthetic_body_block_ids(entity_id.as_str(), &body); + let shadow = ShadowDocument::from_synced_body(entity_id, body, 1, native_block_ids) + .map_err(|error| LocalityError::InvalidState(error.to_string()))? + .with_frontmatter(frontmatter); + + Ok(GmailRenderedEntity { + document, + shadow, + attachment_specs, + }) +} + pub fn thread_remote_id(mailbox: &str, thread_id: &str) -> RemoteId { RemoteId::new(format!("gmail-thread:{mailbox}:{thread_id}")) } @@ -128,7 +190,7 @@ pub fn render_gmail_thread( .iter() .flat_map(collect_attachment_specs) .collect::>(); - let version = thread_remote_version(&bundle.thread); + let version = thread_bundle_remote_version(bundle); let body = thread_body(&bundle.thread); let frontmatter = thread_frontmatter( bundle, @@ -167,9 +229,16 @@ fn thread_frontmatter( attachment_specs: &[GmailAttachmentSpec], ) -> String { let attachments = attachment_frontmatter(attachment_specs); + let latest = latest_thread_message(&bundle.thread); + let latest_headers = latest + .and_then(|message| message.payload.as_ref()) + .map(header_map) + .unwrap_or_default(); + let participants = thread_participants(&bundle.thread); + let associated_drafts = associated_drafts_frontmatter(&bundle.associated_drafts); format!( - "loc:\n id: {}\n type: page\n connector: {}\n synced_at: {}\n remote_edited_at: {}\ntitle: {}\ngmail:\n mailbox: {}\n thread_id: {}\n message_count: {}\n{}", + "loc:\n id: {}\n type: page\n connector: {}\n synced_at: {}\n remote_edited_at: {}\ntitle: {}\ngmail:\n mailbox: {}\n thread_id: {}\n message_count: {}\n latest_message_id: {}\n latest_rfc_message_id: {}\n participants: [{}]\n{}{}", yaml_scalar(remote_id), GMAIL_CONNECTOR_ID, yaml_scalar(version), @@ -178,6 +247,19 @@ fn thread_frontmatter( yaml_scalar(&bundle.mailbox), yaml_scalar(&bundle.thread.id), bundle.thread.messages.len(), + yaml_scalar(latest.map(|message| message.id.as_str()).unwrap_or("")), + yaml_scalar( + latest_headers + .get("message-id") + .map(String::as_str) + .unwrap_or("") + ), + participants + .iter() + .map(|participant| yaml_scalar(participant)) + .collect::>() + .join(", "), + associated_drafts, attachments, ) } @@ -187,6 +269,8 @@ pub fn message_frontmatter(bundle: &GmailNativeBundle) -> String { bundle, None, &RemoteId::new(bundle.message.id.clone()), + None, + None, ) } @@ -194,9 +278,13 @@ fn message_frontmatter_with_attachment_state( bundle: &GmailNativeBundle, attachment_specs: Option<&[GmailAttachmentSpec]>, entity_id: &RemoteId, + draft_id: Option<&str>, + version_override: Option<&str>, ) -> String { let message = &bundle.message; - let version = remote_version(message); + let version = version_override + .map(str::to_string) + .unwrap_or_else(|| remote_version(message)); let headers = message.payload.as_ref().map(header_map).unwrap_or_default(); let subject = headers .get("subject") @@ -205,17 +293,26 @@ fn message_frontmatter_with_attachment_state( let attachments = attachment_specs .map(attachment_frontmatter) .unwrap_or_default(); + let draft_id = draft_id + .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 internal_date: {}\n thread_id: {}\n rfc_message_id: {}\n reply_to: {}\n in_reply_to: {}\n references: [{}]\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.internal_date.as_deref().unwrap_or("")), yaml_scalar(message.thread_id.as_deref().unwrap_or("")), + yaml_scalar(headers.get("message-id").map(String::as_str).unwrap_or("")), + yaml_scalar(headers.get("reply-to").map(String::as_str).unwrap_or("")), + yaml_scalar(headers.get("in-reply-to").map(String::as_str).unwrap_or("")), + yaml_reference_list_items(headers.get("references").map(String::as_str).unwrap_or("")), message .label_ids .iter() @@ -226,11 +323,29 @@ 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("")), ) } +fn associated_drafts_frontmatter(drafts: &[GmailThreadDraftReference]) -> String { + if drafts.is_empty() { + return " associated_drafts: []\n".to_string(); + } + + let mut output = String::from(" associated_drafts:\n"); + for draft in drafts { + output.push_str(&format!( + " - path: {}\n draft_id: {}\n message_id: {}\n", + yaml_scalar(&draft.path), + yaml_scalar(&draft.draft_id), + yaml_scalar(&draft.message_id), + )); + } + output +} + fn attachment_frontmatter(attachment_specs: &[GmailAttachmentSpec]) -> String { if attachment_specs.is_empty() { return " attachments: []\n".to_string(); @@ -264,6 +379,10 @@ pub fn remote_version(message: &GmailMessage) -> String { ) } +pub fn draft_remote_version(draft_id: &str, message: &GmailMessage) -> String { + format!("gmail-draft:{draft_id}:{}", remote_version(message)) +} + pub fn thread_remote_version(thread: &GmailThread) -> String { let mut message_versions = thread .messages @@ -279,6 +398,45 @@ pub fn thread_remote_version(thread: &GmailThread) -> String { ) } +pub fn thread_bundle_remote_version(bundle: &GmailThreadNativeBundle) -> String { + let mut drafts = bundle + .associated_drafts + .iter() + .map(|draft| format!("{}:{}", draft.draft_id, draft.message_id)) + .collect::>(); + drafts.sort(); + format!( + "{}:drafts:{}", + thread_remote_version(&bundle.thread), + drafts.join("|") + ) +} + +pub fn gmail_draft_document_from_message(message: &GmailMessage) -> GmailDraftDocument { + let headers = message.payload.as_ref().map(header_map).unwrap_or_default(); + GmailDraftDocument { + to: split_address_header(headers.get("to").map(String::as_str).unwrap_or("")) + .into_iter() + .map(str::to_string) + .collect(), + cc: split_address_header(headers.get("cc").map(String::as_str).unwrap_or("")) + .into_iter() + .map(str::to_string) + .collect(), + bcc: split_address_header(headers.get("bcc").map(String::as_str).unwrap_or("")) + .into_iter() + .map(str::to_string) + .collect(), + subject: headers.get("subject").cloned().unwrap_or_default(), + body: message_body(message).unwrap_or_default(), + thread_id: message.thread_id.clone(), + in_reply_to: headers.get("in-reply-to").cloned(), + references: split_reference_header( + headers.get("references").map(String::as_str).unwrap_or(""), + ), + } +} + pub fn build_draft_mime(draft: &GmailDraftDocument) -> LocalityResult { build_draft_mime_with_message_id(draft, None) } @@ -315,6 +473,22 @@ pub fn build_draft_mime_with_message_id( mime.push_str(&format!("Bcc: {}\r\n", sanitize_recipients(&draft.bcc))); } mime.push_str(&format!("Subject: {}\r\n", sanitize_header(&draft.subject))); + if let Some(in_reply_to) = draft + .in_reply_to + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + mime.push_str(&format!( + "In-Reply-To: {}\r\n", + sanitize_header(in_reply_to) + )); + } + if !draft.references.is_empty() { + mime.push_str(&format!( + "References: {}\r\n", + sanitize_header(&draft.references.join(" ")) + )); + } if let Some(message_id) = message_id .map(sanitize_message_id) .filter(|value| !value.is_empty()) @@ -364,6 +538,39 @@ fn thread_body(thread: &GmailThread) -> String { output } +fn latest_thread_message(thread: &GmailThread) -> Option<&GmailMessage> { + thread.messages.iter().max_by(|left, right| { + let left_date = left + .internal_date + .as_deref() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let right_date = right + .internal_date + .as_deref() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + left_date + .cmp(&right_date) + .then_with(|| left.id.cmp(&right.id)) + }) +} + +fn thread_participants(thread: &GmailThread) -> Vec { + let mut participants = BTreeSet::new(); + for message in &thread.messages { + let headers = message.payload.as_ref().map(header_map).unwrap_or_default(); + for name in ["from", "to", "cc"] { + for participant in + split_address_header(headers.get(name).map(String::as_str).unwrap_or("")) + { + participants.insert(participant.to_string()); + } + } + } + participants.into_iter().collect() +} + fn message_subject_from_headers(message: &GmailMessage) -> String { message .payload @@ -498,6 +705,23 @@ fn yaml_list_items(header: &str) -> String { .join(", ") } +fn yaml_reference_list_items(header: &str) -> String { + split_reference_header(header) + .iter() + .map(|value| yaml_scalar(value)) + .collect::>() + .join(", ") +} + +fn split_reference_header(header: &str) -> Vec { + header + .split_ascii_whitespace() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + fn split_address_header(header: &str) -> Vec<&str> { let mut parts = Vec::new(); let mut start = 0; @@ -602,11 +826,11 @@ mod tests { use locality_core::LocalityError; use super::{ - GmailDraftDocument, GmailNativeBundle, GmailThreadNativeBundle, build_draft_mime, - message_frontmatter, remote_version, render_gmail_message, render_gmail_thread, - yaml_scalar, + GmailDraftDocument, GmailNativeBundle, GmailThreadDraftReference, GmailThreadNativeBundle, + build_draft_mime, message_frontmatter, remote_version, render_gmail_message, + render_gmail_thread, yaml_scalar, }; - use crate::dto::GmailMessage; + use crate::dto::{GmailDraftCreateRequest, GmailMessage, GmailRawMessage}; #[test] fn renders_plain_text_message_with_gmail_frontmatter() { @@ -684,6 +908,7 @@ mod tests { let rendered = render_gmail_thread(&GmailThreadNativeBundle { mailbox: "inbox".to_string(), thread, + associated_drafts: Vec::new(), }) .expect("render thread"); @@ -705,6 +930,85 @@ mod tests { ); } + #[test] + fn renders_exact_thread_metadata_with_latest_participants_and_draft_references() { + let thread: crate::dto::GmailThread = serde_json::from_value(serde_json::json!({ + "id": "thread-1", + "historyId": "h1", + "messages": [ + { + "id": "msg-1", + "threadId": "thread-1", + "labelIds": ["INBOX"], + "internalDate": "100", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "Ann " }, + { "name": "To", "value": "Me " }, + { "name": "Subject", "value": "Quarterly update" }, + { "name": "Message-ID", "value": "" } + ], + "body": { "data": "Rmlyc3QuCg" } + } + }, + { + "id": "msg-2", + "threadId": "thread-1", + "labelIds": ["SENT"], + "internalDate": "200", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "Me " }, + { "name": "To", "value": "Ann " }, + { "name": "Cc", "value": "Team " }, + { "name": "Subject", "value": "Re: Quarterly update" }, + { "name": "Message-ID", "value": "" } + ], + "body": { "data": "UmVwbHkuCg" } + } + } + ] + })) + .expect("thread"); + let rendered = render_gmail_thread(&GmailThreadNativeBundle { + mailbox: "inbox".to_string(), + thread, + associated_drafts: vec![GmailThreadDraftReference { + draft_id: "draft-7".to_string(), + message_id: "draft-msg-7".to_string(), + path: "draft/200-quarterly-update-draft-7.md".to_string(), + }], + }) + .expect("render thread"); + + assert_eq!( + rendered.document.frontmatter, + concat!( + "loc:\n", + " id: \"gmail-thread:inbox:thread-1\"\n", + " type: page\n", + " connector: gmail\n", + " synced_at: \"gmail-thread:thread-1:h1:gmail:msg-1:100:INBOX|gmail:msg-2:200:SENT:drafts:draft-7:draft-msg-7\"\n", + " remote_edited_at: \"gmail-thread:thread-1:h1:gmail:msg-1:100:INBOX|gmail:msg-2:200:SENT:drafts:draft-7:draft-msg-7\"\n", + "title: \"Quarterly update\"\n", + "gmail:\n", + " mailbox: \"inbox\"\n", + " thread_id: \"thread-1\"\n", + " message_count: 2\n", + " latest_message_id: \"msg-2\"\n", + " latest_rfc_message_id: \"\"\n", + " participants: [\"Ann \", \"Me \", \"Team \"]\n", + " associated_drafts:\n", + " - path: \"draft/200-quarterly-update-draft-7.md\"\n", + " draft_id: \"draft-7\"\n", + " message_id: \"draft-msg-7\"\n", + " attachments: []\n", + ) + ); + } + #[test] fn renders_padded_gmail_body_data() { let message: GmailMessage = serde_json::from_value(serde_json::json!({ @@ -945,6 +1249,9 @@ mod tests { bcc: Vec::new(), subject: "Hello".to_string(), body: "Thanks.\n".to_string(), + thread_id: None, + in_reply_to: None, + references: Vec::new(), }; let mime = build_draft_mime(&draft).expect("mime"); @@ -957,6 +1264,55 @@ mod tests { assert!(!mime.contains("Bcc:")); } + #[test] + fn builds_reply_mime_headers_and_serializes_gmail_thread_id() { + let draft = GmailDraftDocument { + to: vec!["ann@example.com".to_string()], + cc: Vec::new(), + bcc: Vec::new(), + subject: "Re: Hello".to_string(), + body: "Reply body.\n".to_string(), + thread_id: Some("thread-1".to_string()), + in_reply_to: Some("".to_string()), + references: vec![ + "".to_string(), + "".to_string(), + ], + }; + + let mime = build_draft_mime(&draft).expect("mime"); + assert_eq!( + mime, + concat!( + "To: ann@example.com\r\n", + "Subject: Re: Hello\r\n", + "In-Reply-To: \r\n", + "References: \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: text/plain; charset=\"UTF-8\"\r\n", + "Content-Transfer-Encoding: 8bit\r\n", + "\r\n", + "Reply body.\r\n", + ) + ); + + let request = GmailDraftCreateRequest { + message: GmailRawMessage { + raw: "encoded-mime".to_string(), + thread_id: draft.thread_id, + }, + }; + assert_eq!( + serde_json::to_value(request).expect("serialize request"), + serde_json::json!({ + "message": { + "raw": "encoded-mime", + "threadId": "thread-1" + } + }) + ); + } + #[test] fn normalizes_draft_mime_body_line_endings_to_crlf() { let draft = GmailDraftDocument { @@ -965,6 +1321,9 @@ mod tests { bcc: Vec::new(), subject: "Hello".to_string(), body: "Line 1\nLine 2\n".to_string(), + thread_id: None, + in_reply_to: None, + references: Vec::new(), }; let mime = build_draft_mime(&draft).expect("mime"); @@ -1010,6 +1369,9 @@ mod tests { bcc: Vec::new(), subject: String::new(), body: "Body".to_string(), + thread_id: None, + in_reply_to: None, + references: Vec::new(), }; let error = build_draft_mime(&draft).expect_err("invalid draft"); diff --git a/crates/locality-gmail/src/settings.rs b/crates/locality-gmail/src/settings.rs index 341ab2d5..dd7a8914 100644 --- a/crates/locality-gmail/src/settings.rs +++ b/crates/locality-gmail/src/settings.rs @@ -3,16 +3,20 @@ use std::fmt; use locality_core::{LocalityError, LocalityResult}; use serde::{Deserialize, Deserializer, Serialize, de}; +pub const GMAIL_PROJECTION_LAYOUT_VERSION: u32 = 2; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct GmailMountSettings { pub gmail: GmailSettings, + pub projection_layout_version: u32, } impl Default for GmailMountSettings { fn default() -> Self { Self { gmail: GmailSettings::default(), + projection_layout_version: GMAIL_PROJECTION_LAYOUT_VERSION, } } } @@ -20,9 +24,10 @@ impl Default for GmailMountSettings { impl GmailMountSettings { pub fn from_json(value: &str) -> LocalityResult { if value.trim().is_empty() { - return Ok(Self::default()); + return Err(legacy_implicit_projection_settings()); } - serde_json::from_str::(value).map_err(|error| { + + let raw = serde_json::from_str::(value).map_err(|error| { LocalityError::Validation(vec![locality_core::validation::ValidationIssue::new( "gmail_mount_settings_invalid", std::path::PathBuf::new(), @@ -30,7 +35,26 @@ impl GmailMountSettings { format!("Gmail mount settings JSON is invalid: {error}"), Some("remount Gmail with valid --after/--before/--view options".to_string()), )]) - }) + })?; + if raw.as_object().is_some_and(serde_json::Map::is_empty) { + return Err(legacy_implicit_projection_settings()); + } + + let settings = serde_json::from_value::(raw).map_err(|error| { + LocalityError::Validation(vec![locality_core::validation::ValidationIssue::new( + "gmail_mount_settings_invalid", + std::path::PathBuf::new(), + Some(1), + format!("Gmail mount settings JSON is invalid: {error}"), + Some("remount Gmail with valid --after/--before/--view options".to_string()), + )]) + })?; + if settings.projection_layout_version != GMAIL_PROJECTION_LAYOUT_VERSION { + return Err(projection_layout_version_error( + settings.projection_layout_version, + )); + } + Ok(settings) } pub fn to_json(&self) -> LocalityResult { @@ -42,8 +66,9 @@ impl GmailMountSettings { Ok(Self { gmail: GmailSettings { date_window: Some(GmailDateWindow::new(after, before)?), - view: GmailProjectionView::Messages, + view: GmailProjectionView::Threads, }, + projection_layout_version: GMAIL_PROJECTION_LAYOUT_VERSION, }) } @@ -53,6 +78,27 @@ impl GmailMountSettings { } } +fn legacy_implicit_projection_settings() -> LocalityError { + LocalityError::Validation(vec![locality_core::validation::ValidationIssue::new( + "gmail_projection_layout_upgrade_required", + std::path::PathBuf::new(), + Some(1), + "Gmail mount uses legacy implicit settings (`{}`), whose message layout cannot be safely changed in place to the thread-default layout", + Some( + "preserve the existing mount with `--view messages`, or create a new mount ID and root for the thread layout" + .to_string(), + ), + )]) +} + +fn projection_layout_version_error(version: u32) -> LocalityError { + LocalityError::UpdateRequired { + component: "gmail:projection_layout".to_string(), + found: i64::from(version), + supported: i64::from(GMAIL_PROJECTION_LAYOUT_VERSION), + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct GmailSettings { @@ -64,7 +110,7 @@ impl Default for GmailSettings { fn default() -> Self { Self { date_window: None, - view: GmailProjectionView::Messages, + view: GmailProjectionView::Threads, } } } @@ -78,7 +124,7 @@ pub enum GmailProjectionView { impl Default for GmailProjectionView { fn default() -> Self { - Self::Messages + Self::Threads } } @@ -258,23 +304,57 @@ fn error_message(error: LocalityError) -> String { #[cfg(test)] mod tests { - use super::{GmailMountSettings, GmailProjectionView, GmailSearchDate}; + use super::{ + GMAIL_PROJECTION_LAYOUT_VERSION, GmailMountSettings, GmailProjectionView, GmailSearchDate, + }; use locality_core::LocalityError; #[test] - fn default_settings_keep_message_view_without_date_window() { - let settings = GmailMountSettings::from_json("{}").expect("settings"); + fn default_settings_keep_thread_view_without_date_window() { + let settings = GmailMountSettings::default(); assert_eq!(settings.gmail.date_window, None); - assert_eq!(settings.gmail.view, GmailProjectionView::Messages); + assert_eq!(settings.gmail.view, GmailProjectionView::Threads); + assert_eq!( + settings.projection_layout_version, + GMAIL_PROJECTION_LAYOUT_VERSION + ); } #[test] - fn blank_settings_decode_as_default() { - let settings = GmailMountSettings::from_json(" \n\t ").expect("settings"); + fn legacy_implicit_settings_fail_before_changing_projection_layout() { + for json in ["{}", " \n\t "] { + let error = GmailMountSettings::from_json(json) + .expect_err("legacy implicit settings require an explicit layout"); + let LocalityError::Validation(issues) = error else { + panic!("expected validation error"); + }; + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].code, "gmail_projection_layout_upgrade_required"); + assert_eq!( + issues[0].message, + "Gmail mount uses legacy implicit settings (`{}`), whose message layout cannot be safely changed in place to the thread-default layout" + ); + assert_eq!( + issues[0].suggested_fix.as_deref(), + Some( + "preserve the existing mount with `--view messages`, or create a new mount ID and root for the thread layout" + ) + ); + } + } + + #[test] + fn explicit_unversioned_message_view_remains_an_unambiguous_opt_in() { + let settings = GmailMountSettings::from_json(r#"{"gmail":{"view":"messages"}}"#) + .expect("explicit message view"); - assert_eq!(settings.gmail.date_window, None); assert_eq!(settings.gmail.view, GmailProjectionView::Messages); + assert_eq!( + settings.projection_layout_version, + GMAIL_PROJECTION_LAYOUT_VERSION + ); } #[test] @@ -293,7 +373,7 @@ mod tests { let json = settings.to_json().expect("json"); assert_eq!( json, - r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15"},"view":"threads"}}"# + r#"{"gmail":{"date_window":{"after":"2026-07-01","before":"2026-07-15"},"view":"threads"},"projection_layout_version":2}"# ); let parsed = GmailMountSettings::from_json(&json).expect("parsed json"); @@ -305,6 +385,30 @@ mod tests { ); } + #[test] + fn newer_projection_layout_version_fails_cleanly() { + let error = GmailMountSettings::from_json( + r#"{"gmail":{"view":"threads"},"projection_layout_version":3}"#, + ) + .expect_err("newer layout requires a newer build"); + assert_eq!( + error, + LocalityError::UpdateRequired { + component: "gmail:projection_layout".to_string(), + found: 3, + supported: 2, + } + ); + } + + #[test] + fn date_window_keeps_thread_view_without_an_explicit_override() { + let settings = + GmailMountSettings::with_date_window("2026-07-01", "2026-07-15").expect("date window"); + + assert_eq!(settings.gmail.view, GmailProjectionView::Threads); + } + #[test] fn date_window_accessors_expose_validated_dates() { let window = GmailMountSettings::with_date_window("2026-07-01", "2026-07-15") diff --git a/crates/localityd/src/gmail.rs b/crates/localityd/src/gmail.rs index 08703710..14c25bb3 100644 --- a/crates/localityd/src/gmail.rs +++ b/crates/localityd/src/gmail.rs @@ -1,19 +1,25 @@ +use std::collections::BTreeSet; use std::path::{Component, Path}; use std::time::{SystemTime, UNIX_EPOCH}; use locality_connector::oauth_broker::OAuthBrokerRefresh; use locality_connector::{Connector, EnumerateRequest, FetchRequest}; +use locality_core::canonical::{ + LocalityMetadata, ParsedCanonicalDocument, parse_canonical_markdown, render_canonical_markdown, +}; use locality_core::diff::property_value_from_frontmatter; use locality_core::hydration::HydrationRequest; -use locality_core::model::{RemoteId, TreeEntry}; +use locality_core::model::{CanonicalDocument, RemoteId, TreeEntry}; use locality_core::planner::PropertyValue; use locality_core::validation::{ValidationIssue, ValidationReport}; use locality_core::{LocalityError, LocalityResult}; use locality_gmail::attachments::{GmailAttachmentSpec, decode_attachment_body}; use locality_gmail::client::GmailApi; use locality_gmail::render::{ - GmailNativeBundle, GmailThreadMessageNativeBundle, GmailThreadNativeBundle, remote_version, - render_gmail_message, render_gmail_thread, render_gmail_thread_message, thread_remote_version, + GmailDraftNativeBundle, GmailNativeBundle, GmailThreadMessageNativeBundle, + GmailThreadNativeBundle, draft_remote_version, remote_version, render_gmail_draft, + render_gmail_message, render_gmail_thread, render_gmail_thread_message, + thread_bundle_remote_version, }; use locality_gmail::{ GMAIL_CONNECTOR_ID, GmailConfig, GmailConnector, GmailMountSettings, GmailOAuthScopeError, @@ -110,13 +116,23 @@ fn gmail_config_from_mount( token: String, mount: &MountConfig, ) -> Result { - let settings = GmailMountSettings::from_json(&mount.settings_json).map_err(|error| { - ConnectorResolveError::CredentialStoreUnavailable(format!( - "Gmail mount `{}` settings are invalid: {}", - mount.mount_id.0, - gmail_settings_error_message(error) - )) - })?; + let settings = + GmailMountSettings::from_json(&mount.settings_json).map_err(|error| match error { + LocalityError::UpdateRequired { + component, + found, + supported, + } => ConnectorResolveError::UpdateRequired { + component, + found, + supported, + }, + other => ConnectorResolveError::ConnectorSettingsInvalid(format!( + "Gmail mount `{}` settings are invalid: {}", + mount.mount_id.0, + gmail_settings_error_message(other) + )), + })?; Ok(GmailConfig::new(token).with_settings(settings)) } @@ -335,19 +351,65 @@ pub(crate) fn validate_gmail_changed_frontmatter( context: SourceValidationContext<'_>, ) -> LocalityResult { let mut report = ValidationReport::clean(); - if gmail_mailbox_from_path(context.relative_path) - .is_some_and(|mailbox| matches!(mailbox, "inbox" | "sent")) - { + if !is_direct_draft_child(context.relative_path) { report.push(ValidationIssue::new( "gmail_read_only_mailbox", context.relative_path, Some(1), - "Gmail inbox and sent items are read-only", + "Gmail inbox, sent mail, and thread projections are read-only", Some( - "create a new Markdown file directly under draft/ to create an unsent Gmail draft" + "edit an existing Markdown file directly under draft/ or create a new unsent Gmail draft there" .to_string(), ), )); + return Ok(report); + } + + validate_gmail_draft_required_fields(context, &mut report); + + let Some(shadow) = context.shadow else { + return Ok(report); + }; + let shadow = parse_canonical_markdown(&render_canonical_markdown(&CanonicalDocument::new( + shadow.frontmatter.clone(), + shadow.rendered_body.clone(), + ))) + .map_err(|error| { + LocalityError::InvalidState(format!( + "synced Gmail shadow frontmatter is no longer parseable: {error}" + )) + })?; + + if !gmail_identity_metadata_matches( + shadow.frontmatter.loc.as_ref(), + context.parsed.frontmatter.loc.as_ref(), + ) { + report.push(ValidationIssue::new( + "gmail_immutable_identity", + context.relative_path, + Some(1), + "Gmail Locality identity metadata is read-only", + Some("restore the generated `loc` frontmatter".to_string()), + )); + } + + for key in frontmatter_changed_keys(&shadow, context.parsed) { + if matches!(key.as_str(), "to" | "cc" | "bcc" | "subject") { + continue; + } + if key == "gmail" + && shadow.frontmatter.properties.get("gmail") + == context.parsed.frontmatter.properties.get("gmail") + { + continue; + } + report.push(ValidationIssue::new( + "gmail_immutable_frontmatter", + context.relative_path, + Some(1), + format!("Gmail frontmatter `{key}` is read-only"), + Some(format!("restore generated Gmail `{key}` frontmatter")), + )); } Ok(report) } @@ -367,6 +429,25 @@ pub(crate) fn validate_gmail_create_frontmatter( )); } + validate_gmail_draft_required_fields(context, &mut report); + + if gmail_draft_frontmatter_has_attachments(&context.parsed.frontmatter.properties) { + report.push(ValidationIssue::new( + "gmail_attachments_unsupported", + context.relative_path, + Some(1), + "Gmail draft creation does not support attachments", + Some("remove attachment frontmatter".to_string()), + )); + } + + Ok(report) +} + +fn validate_gmail_draft_required_fields( + context: SourceValidationContext<'_>, + report: &mut ValidationReport, +) { let has_subject = frontmatter_string(&context.parsed.frontmatter.properties, "subject") .as_deref() .is_some_and(|subject| !subject.trim().is_empty()) @@ -395,18 +476,38 @@ pub(crate) fn validate_gmail_create_frontmatter( Some("add `to: [\"name@example.com\"]` to the frontmatter".to_string()), )); } +} - if gmail_draft_frontmatter_has_attachments(&context.parsed.frontmatter.properties) { - report.push(ValidationIssue::new( - "gmail_attachments_unsupported", - context.relative_path, - Some(1), - "Gmail draft creation does not support attachments", - Some("remove attachment frontmatter".to_string()), - )); +fn gmail_identity_metadata_matches( + synced: Option<&LocalityMetadata>, + edited: Option<&LocalityMetadata>, +) -> bool { + match (synced, edited) { + (Some(synced), Some(edited)) => { + synced.id == edited.id + && synced.entity_type == edited.entity_type + && synced.raw_entity_type == edited.raw_entity_type + && synced.parent == edited.parent + } + (None, None) => true, + _ => false, } +} - Ok(report) +fn frontmatter_changed_keys( + synced: &ParsedCanonicalDocument, + edited: &ParsedCanonicalDocument, +) -> BTreeSet { + synced + .frontmatter + .properties + .keys() + .chain(edited.frontmatter.properties.keys()) + .filter(|key| { + synced.frontmatter.properties.get(*key) != edited.frontmatter.properties.get(*key) + }) + .cloned() + .collect() } fn gmail_draft_frontmatter_has_attachments( @@ -452,15 +553,6 @@ fn frontmatter_string( }) } -fn gmail_mailbox_from_path(path: &Path) -> Option<&str> { - path.components() - .next() - .and_then(|component| match component { - Component::Normal(value) => value.to_str(), - _ => None, - }) -} - fn is_direct_draft_child(path: &Path) -> bool { let mut components = path.components(); matches!( @@ -484,7 +576,22 @@ impl HydrationSource for GmailConnector { return Ok(HydratedEntity { document: rendered.document, shadow: rendered.shadow, - remote_edited_at: Some(thread_remote_version(&bundle.thread)), + remote_edited_at: Some(thread_bundle_remote_version(&bundle)), + assets, + }); + } + + if native.kind == "gmail_draft" { + let bundle = + serde_json::from_slice::(&native.raw).map_err(|error| { + LocalityError::Io(format!("gmail draft native decode failed: {error}")) + })?; + let rendered = render_gmail_draft(&bundle)?; + let assets = gmail_attachment_assets(self.api(), &rendered.attachment_specs)?; + return Ok(HydratedEntity { + document: rendered.document, + shadow: rendered.shadow, + remote_edited_at: Some(draft_remote_version(&bundle.draft_id, &bundle.message)), assets, }); } @@ -570,7 +677,7 @@ mod tests { use locality_gmail::attachments::attachment_local_path; use locality_gmail::client::GmailApi; use locality_gmail::dto::{ - GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList, + GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailMessage, GmailMessageList, GmailMessagePartBody, GmailThread, GmailThreadList, }; @@ -649,8 +756,12 @@ mod tests { assert_eq!(hydrated.assets[0].bytes, b"attachment bytes"); assert_eq!( hydrated.remote_edited_at, - Some(locality_gmail::render::thread_remote_version( - &thread_fixture("thread-attach") + Some(locality_gmail::render::thread_bundle_remote_version( + &GmailThreadNativeBundle { + mailbox: "inbox".to_string(), + thread: thread_fixture("thread-attach"), + associated_drafts: Vec::new(), + } )) ); assert!( @@ -708,6 +819,41 @@ mod tests { ); } + #[test] + fn gmail_hydration_renders_remote_draft_with_stable_draft_identity() { + let api = Arc::new(FakeGmailApi::default()); + let connector = GmailConnector::with_api(GmailConfig::new("token"), api.clone()); + let request = HydrationRequest::new( + MountId::new("gmail-main"), + RemoteId::new("gmail-draft:draft-1"), + "draft/attachments-draft-1.md", + HydrationState::Hydrated, + HydrationReason::ExplicitPull, + ); + + let hydrated = connector.fetch_render(&request).expect("hydrate draft"); + + assert_eq!(hydrated.shadow.entity_id.as_str(), "gmail-draft:draft-1"); + assert!( + hydrated + .document + .frontmatter + .contains("draft_id: \"draft-1\"") + ); + assert_eq!(hydrated.assets.len(), 1); + assert_eq!( + hydrated.remote_edited_at, + Some(locality_gmail::render::draft_remote_version( + "draft-1", + &message_fixture("draft-msg-1"), + )) + ); + assert_eq!( + api.calls.lock().expect("calls").attachments, + vec![("draft-msg-1".to_string(), "attach-1".to_string())] + ); + } + #[derive(Debug)] struct FakeGmailApi { calls: Mutex, @@ -797,7 +943,30 @@ mod tests { panic!("not used") } - fn send_draft(&self, _request: GmailDraftSendRequest) -> LocalityResult { + fn list_drafts( + &self, + _max_results: u32, + _page_token: Option<&str>, + ) -> LocalityResult { + Ok(GmailDraftList::default()) + } + + fn get_draft_metadata(&self, _draft_id: &str) -> LocalityResult { + panic!("not used") + } + + fn get_draft_full(&self, _draft_id: &str) -> LocalityResult { + Ok(GmailDraft { + id: _draft_id.to_string(), + message: message_fixture("draft-msg-1"), + }) + } + + fn update_draft( + &self, + _draft_id: &str, + _request: GmailDraftCreateRequest, + ) -> LocalityResult { panic!("not used") } } diff --git a/crates/localityd/src/notion.rs b/crates/localityd/src/notion.rs index ac07eb63..b2e3aa14 100644 --- a/crates/localityd/src/notion.rs +++ b/crates/localityd/src/notion.rs @@ -47,6 +47,12 @@ pub enum ConnectorResolveError { profile_id: String, suggested_command: String, }, + UpdateRequired { + component: String, + found: i64, + supported: i64, + }, + ConnectorSettingsInvalid(String), CredentialStoreUnavailable(String), } @@ -59,6 +65,8 @@ impl ConnectorResolveError { Self::AuthRequired { .. } => "auth_required", Self::ConnectionRevoked { .. } => "connection_revoked", Self::AuthProfileUnavailable { .. } => "auth_profile_unavailable", + Self::UpdateRequired { .. } => "update_required", + Self::ConnectorSettingsInvalid(_) => "connector_settings_invalid", Self::CredentialStoreUnavailable(_) => "credential_store_unavailable", } } @@ -83,6 +91,14 @@ impl ConnectorResolveError { Self::AuthProfileUnavailable { profile_id, .. } => { format!("connector profile `{profile_id}` is unavailable") } + Self::UpdateRequired { + component, + found, + supported, + } => format!( + "update required for {component}: found version {found}, supported version {supported}" + ), + Self::ConnectorSettingsInvalid(message) => message.clone(), Self::CredentialStoreUnavailable(message) => message.clone(), } } @@ -108,7 +124,18 @@ impl ConnectorResolveError { impl From for LocalityError { fn from(value: ConnectorResolveError) -> Self { - LocalityError::InvalidState(value.message()) + match value { + ConnectorResolveError::UpdateRequired { + component, + found, + supported, + } => LocalityError::UpdateRequired { + component, + found, + supported, + }, + other => LocalityError::InvalidState(other.message()), + } } } diff --git a/crates/localityd/src/push.rs b/crates/localityd/src/push.rs index 10cb1466..953c7a6d 100644 --- a/crates/localityd/src/push.rs +++ b/crates/localityd/src/push.rs @@ -320,15 +320,17 @@ where + VirtualMutationRepository, Source: Connector + HydrationSource + ?Sized, { - if let Some(report) = block_ambiguous_gmail_send_replay(store, &prepared)? { - return Ok(report); - } if let Some(report) = resume_failed_applied_reconciliation(store, source, &prepared, state_root)? { return Ok(report); } + // Gmail derives a draft's RFC Message-ID from the push and operation IDs. + // Reusing an incomplete draft-create journal's idempotency key lets the + // connector find a draft created before a crash instead of creating a + // second one, while recording this retry in a distinct local journal. + let idempotency_push_id = retryable_gmail_draft_create_push_id(store, &prepared)?; let push_id = generate_push_id(); let remote_preconditions = remote_preconditions_for_plan( store, @@ -363,6 +365,10 @@ where ) .with_readable_diff(readable_diff.clone()); + if let Some(idempotency_push_id) = idempotency_push_id { + execution_request = execution_request.with_idempotency_push_id(idempotency_push_id); + } + if !prepared.shadows.is_empty() { execution_request = execution_request.with_preimages( prepared @@ -456,10 +462,10 @@ where Ok(items) } -fn block_ambiguous_gmail_send_replay( +fn retryable_gmail_draft_create_push_id( store: &S, prepared: &PreparedPush, -) -> LocalityResult> +) -> LocalityResult> where S: JournalRepository, { @@ -480,63 +486,27 @@ where return Ok(None); } - let Some(journal) = latest_ambiguous_gmail_send_journal(store, &prepared.mount.mount_id, plan)? - else { - return Ok(None); - }; - let error = LocalityError::Guardrail( - "a previous Gmail send for this draft has an ambiguous result and may have already sent; inspect Gmail Sent Mail and the Locality journal before retrying" - .to_string(), - ); - - 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)), - })) -} - -fn latest_ambiguous_gmail_send_journal( - store: &S, - mount_id: &MountId, - plan: &PushPlan, -) -> LocalityResult> -where - S: JournalRepository, -{ - let mut latest = None; + let mut retryable = 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) + if journal.mount_id != prepared.mount.mount_id + || journal.plan != *plan + || !journal.apply_effects.is_empty() + || !matches!( + journal.status, + JournalStatus::Applying | JournalStatus::Failed(_) + ) { continue; } - if latest + if retryable .as_ref() - .is_none_or(|current| journal_is_newer(&journal, current)) + .is_none_or(|current: &JournalEntry| journal_is_newer(&journal, current)) { - latest = Some(journal); + retryable = Some(journal); } } - Ok(latest.filter(|journal: &JournalEntry| { - journal.apply_effects.is_empty() && ambiguous_gmail_send_status(&journal.status) - })) -} - -fn ambiguous_gmail_send_status(status: &JournalStatus) -> bool { - match status { - JournalStatus::Applying => true, - JournalStatus::Failed(message) => message.contains("gmail draft send"), - _ => false, - } + Ok(retryable.map(|journal| journal.push_id)) } fn resume_failed_applied_reconciliation( @@ -836,14 +806,6 @@ fn journal_created_entity_sources_match(journal: &JournalEntry, plan: &PushPlan) current_sources.is_empty() } -fn journal_created_entity_source_paths_match(left: &PushPlan, right: &PushPlan) -> bool { - let mut left_sources = plan_create_entity_sources(left); - let mut right_sources = plan_create_entity_sources(right); - left_sources.sort(); - right_sources.sort(); - left_sources == right_sources -} - fn plan_create_entity_sources(plan: &PushPlan) -> Vec<(&RemoteId, &PathBuf)> { plan.operations .iter() diff --git a/crates/localityd/src/source.rs b/crates/localityd/src/source.rs index 900a9ceb..84543264 100644 --- a/crates/localityd/src/source.rs +++ b/crates/localityd/src/source.rs @@ -307,6 +307,23 @@ pub fn source_write_decision_for_path( SourceWriteDecision::Writable } +pub fn source_delete_decision_for_path( + mount: &MountConfig, + relative_path: &Path, +) -> SourceWriteDecision { + if mount.read_only { + return SourceWriteDecision::ReadOnly { + reason: "mount is read-only", + }; + } + if mount.connector == "gmail" { + return SourceWriteDecision::ReadOnly { + reason: "Gmail draft deletion is not supported", + }; + } + source_write_decision_for_path(mount, relative_path) +} + pub fn source_create_decision_for_parent_path( mount: &MountConfig, parent_path: &Path, @@ -469,7 +486,7 @@ 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, + body_diff_mode: BodyDiffMode::WholeEntity, virtual_rename_policy: VirtualRenamePolicy::FilenameDerived, max_background_discovery_workers: 4, } diff --git a/crates/localityd/src/virtual_fs.rs b/crates/localityd/src/virtual_fs.rs index e9915178..0dd88c96 100644 --- a/crates/localityd/src/virtual_fs.rs +++ b/crates/localityd/src/virtual_fs.rs @@ -30,8 +30,8 @@ use crate::hydration::{ }; use crate::shadow_match::parsed_matches_shadow; use crate::source::{ - VirtualRenamePolicy, source_create_decision_for_parent_path, source_descriptor, - source_move_decision_for_parent_path, source_write_decision_for_path, + VirtualRenamePolicy, source_create_decision_for_parent_path, source_delete_decision_for_path, + source_descriptor, source_move_decision_for_parent_path, source_write_decision_for_path, }; pub const ROOT_CONTAINER_IDENTIFIER: &str = "root"; @@ -1658,6 +1658,7 @@ where let mutation = pending_page_directory_mutation(&mutations, identifier)? .ok_or_else(|| missing_identifier(identifier))? .clone(); + ensure_source_path_deletable(&mount, &mutation.projected_path)?; let path = content_path_for_relative(content_root, &mutation.projected_path)?; let _ = std::fs::remove_file(path); store @@ -1680,11 +1681,12 @@ where "only page directories can be deleted by the virtual filesystem", )); } - ensure_source_path_writable(&mount, &entity.path)?; + ensure_source_path_deletable(&mount, &entity.path)?; return record_virtual_fs_page_delete(store, content_root, &mount, &entities, entity, true); } if let Some(mutation) = local_mutation(store, mount_id, identifier)? { + ensure_source_path_deletable(&mount, &mutation.projected_path)?; let path = content_path_for_relative(content_root, &mutation.projected_path)?; let _ = std::fs::remove_file(path); store @@ -1701,7 +1703,7 @@ where "only page.md files can be deleted by the virtual filesystem", )); } - ensure_source_path_writable(&mount, &entity.path)?; + ensure_source_path_deletable(&mount, &entity.path)?; record_virtual_fs_page_delete(store, content_root, &mount, &entities, entity, false) } @@ -3842,6 +3844,15 @@ fn ensure_source_path_writable(mount: &MountConfig, relative_path: &Path) -> Loc } } +fn ensure_source_path_deletable(mount: &MountConfig, relative_path: &Path) -> LocalityResult<()> { + match source_delete_decision_for_path(mount, relative_path) { + crate::source::SourceWriteDecision::Writable => Ok(()), + crate::source::SourceWriteDecision::ReadOnly { reason } => { + Err(LocalityError::Unsupported(reason)) + } + } +} + fn ensure_source_parent_accepts_create( mount: &MountConfig, parent_path: &Path, @@ -8672,6 +8683,41 @@ mod tests { let _ = std::fs::remove_dir_all(state_root); } + #[test] + fn trash_gmail_draft_is_rejected_without_recording_a_delete_mutation() { + let mount_id = MountId::new("gmail-main"); + let state_root = temp_root("loc-virtual-fs-trash-gmail-draft"); + let content_root = state_root.join("content/gmail-main/files"); + let mut store = InMemoryStateStore::new(); + store + .save_mount(virtual_mount_with_connector(&mount_id, "gmail")) + .expect("save mount"); + store + .save_entity(EntityRecord::new( + mount_id.clone(), + RemoteId::new("gmail-draft-1"), + EntityKind::Page, + "Draft reply", + "draft/reply.md", + )) + .expect("save draft"); + + let error = trash_virtual_fs_item(&mut store, &content_root, &mount_id, "gmail-draft-1") + .expect_err("Gmail draft deletion must be blocked"); + + assert_eq!( + error, + LocalityError::Unsupported("Gmail draft deletion is not supported") + ); + assert!( + store + .list_virtual_mutations(&mount_id) + .expect("list mutations") + .is_empty() + ); + let _ = std::fs::remove_dir_all(state_root); + } + #[test] fn trash_pending_page_directory_discards_overlay_and_cache() { let mount_id = MountId::new("notion-main"); diff --git a/crates/localityd/tests/push_execution.rs b/crates/localityd/tests/push_execution.rs index 649571c6..600fee2d 100644 --- a/crates/localityd/tests/push_execution.rs +++ b/crates/localityd/tests/push_execution.rs @@ -1122,7 +1122,7 @@ fn daemon_push_reconciles_gmail_draft_create_to_draft_folder() { let message = store .get_entity(&fixture.mount_id, &created_remote_id) .expect("get draft message") - .expect("sent message entity"); + .expect("draft message entity"); assert_eq!(message.path, PathBuf::from("draft/reply.md")); assert_eq!(source.requested_paths(), vec![message.path.clone()]); assert!(content_root.join(source_path).exists()); @@ -1147,6 +1147,135 @@ fn daemon_push_reconciles_gmail_draft_create_to_draft_folder() { ); } +#[test] +fn daemon_push_updates_hydrated_gmail_draft_in_place() { + let fixture = PushFixture::new(); + let remote_id = RemoteId::new("gmail-draft:draft-1"); + let draft_path = PathBuf::from("draft/reply.md"); + let before = + rendered_gmail_draft_entity(remote_id.as_str(), "Original subject", "Original body.\n"); + let after = + rendered_gmail_draft_entity(remote_id.as_str(), "Updated subject", "Updated body.\n"); + let mut edited = before.document.clone(); + edited.frontmatter = edited + .frontmatter + .replace("Original subject", "Updated subject"); + edited.body = "Updated body.\n".to_string(); + fs::create_dir_all(fixture.root.join("draft")).expect("create draft directory"); + fs::write( + fixture.root.join(&draft_path), + render_canonical_markdown(&edited), + ) + .expect("write edited draft"); + + let mut store = InMemoryStateStore::new(); + store + .save_mount(MountConfig::new( + fixture.mount_id.clone(), + "gmail", + fixture.root.clone(), + )) + .expect("save Gmail mount"); + store + .save_entity(EntityRecord::new( + fixture.mount_id.clone(), + RemoteId::new("gmail-folder:draft"), + EntityKind::Directory, + "draft", + "draft", + )) + .expect("save draft folder"); + store + .save_entity( + EntityRecord::new( + fixture.mount_id.clone(), + remote_id.clone(), + EntityKind::Page, + "Original subject", + draft_path.clone(), + ) + .with_hydration(HydrationState::Hydrated) + .with_remote_edited_at( + before + .remote_edited_at + .clone() + .expect("before remote version"), + ), + ) + .expect("save hydrated draft"); + store + .save_shadow(&fixture.mount_id, before.shadow.clone()) + .expect("save draft shadow"); + + let source = + FakePushSource::with_remote_transition_for(remote_id.clone(), before, after.clone()) + .with_supported_operations( + [ + PushOperationKind::UpdateEntityBody, + PushOperationKind::UpdateProperties, + ] + .into_iter() + .collect(), + ); + let report = execute_push_job_with_content_root( + &mut store, + PushJob { + target_path: fixture.root.join(&draft_path), + assume_yes: true, + confirm_dangerous: false, + }, + &source, + None, + ) + .expect("push existing Gmail draft update"); + + assert_eq!(report.action, PushJobAction::Reconciled, "{report:#?}"); + assert_eq!(source.applied_count(), 1); + let operations = &report + .pipeline + .plan + .as_ref() + .expect("update plan") + .operations; + assert_eq!(operations.len(), 2, "{operations:#?}"); + assert!(operations.iter().any(|operation| matches!( + operation, + PushOperation::UpdateEntityBody { entity_id, body } + if entity_id == &remote_id && body == "Updated body.\n" + ))); + assert!(operations.iter().any(|operation| matches!( + operation, + PushOperation::UpdateProperties { + entity_id, + properties, + } if entity_id == &remote_id + && properties.get("subject") + == Some(&PropertyValue::String("Updated subject".to_string())) + ))); + assert!(operations.iter().all(|operation| !matches!( + operation, + PushOperation::CreateEntity { .. } | PushOperation::MoveEntity { .. } + ))); + + let reconciled = store + .get_entity(&fixture.mount_id, &remote_id) + .expect("load reconciled draft") + .expect("reconciled draft exists"); + assert_eq!(reconciled.path, draft_path); + assert_eq!(reconciled.hydration, HydrationState::Hydrated); + assert_eq!( + fs::read_to_string(fixture.root.join(&reconciled.path)).expect("read reconciled draft"), + render_canonical_markdown(&after.document) + ); + assert!( + store + .list_entities(&fixture.mount_id) + .expect("list Gmail entities") + .iter() + .all(|entity| !entity.path.starts_with("sent")) + ); +} + #[test] fn daemon_push_reconciles_google_calendar_draft_create_to_canonical_event_filename() { let fixture = PushFixture::new(); @@ -1197,7 +1326,7 @@ fn daemon_push_reconciles_google_calendar_draft_create_to_canonical_event_filena "local:calendar-draft", VirtualMutationKind::Create, None, - Some(draft_folder_id), + Some(draft_folder_id.clone()), "draft/design-review.md", Some(cache_path), )) @@ -1304,7 +1433,7 @@ fn daemon_push_accepts_google_calendar_summary_only_draft_create() { "local:calendar-summary-only-draft", VirtualMutationKind::Create, None, - Some(draft_folder_id), + Some(draft_folder_id.clone()), "draft/summary-only.md", Some(cache_path), )) @@ -1560,7 +1689,7 @@ fn auto_save_push_blocks_google_calendar_draft_create_without_applying() { } #[test] -fn auto_save_push_blocks_gmail_draft_send_without_applying() { +fn auto_save_push_blocks_gmail_draft_create_without_applying() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/reply.md"); @@ -1574,8 +1703,7 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { .expect("cache file"); let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let created_remote_id = RemoteId::new("gmail-draft:created-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -1598,7 +1726,7 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { "local:gmail-draft", VirtualMutationKind::Create, None, - Some(draft_folder_id), + Some(draft_folder_id.clone()), "draft/reply.md", Some(cache_path), )) @@ -1615,7 +1743,7 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { FakePushSource::default().with_apply_effects(vec![JournalApplyEffect::CreatedEntity { operation_id: PushOperationId("create-gmail-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id, + parent_id: draft_folder_id, entity_id: created_remote_id, }]); @@ -1632,7 +1760,11 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { .expect("auto-save gmail draft"); assert_eq!(report.action, PushJobAction::NotReady); - assert_eq!(source.applied_count(), 0, "auto-save must not send Gmail"); + assert_eq!( + source.applied_count(), + 0, + "auto-save must not create Gmail drafts" + ); assert_eq!( report.error.as_ref().expect("error").code, "auto_save_blocked" @@ -1654,7 +1786,7 @@ fn auto_save_push_blocks_gmail_draft_send_without_applying() { } #[test] -fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { +fn daemon_push_resumes_failed_gmail_draft_reconciliation_without_reapplying() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/reply.md"); @@ -1669,8 +1801,7 @@ fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { .expect("cache file"); let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let created_remote_id = RemoteId::new("gmail-draft:created-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -1687,22 +1818,13 @@ fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { "draft", )) .expect("save draft 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-draft", VirtualMutationKind::Create, None, - Some(draft_folder_id), + Some(draft_folder_id.clone()), "draft/reply.md", Some(cache_path), )) @@ -1710,13 +1832,13 @@ fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { let source = FakePushSource::default() .with_created_entity( created_remote_id.clone(), - rendered_entity("gmail-message:sent-1", "Body."), + rendered_entity("gmail-draft:created-1", "Body."), ) .with_created_fetch_failures(created_remote_id.clone(), 1) .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { operation_id: PushOperationId("create-gmail-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id, + parent_id: draft_folder_id, entity_id: created_remote_id.clone(), }]); let job = || PushJob { @@ -1747,32 +1869,36 @@ fn daemon_push_resumes_failed_gmail_send_reconciliation_without_reapplying() { .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 the Gmail draft" + ); 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()); + .expect("get draft") + .expect("draft entity"); + assert_eq!(message.path, PathBuf::from("draft/reply.md")); + assert!(content_root.join("draft/reply.md").exists()); assert!(content_root.join(source_path).exists()); - assert_eq!( - fs::read_to_string(content_root.join(source_path)).expect("preserved edited draft"), - "---\ntitle: Edited reply\nto: [\"user@example.com\"]\nsubject: Edited reply\n---\nChanged body.\n" - ); + let reconciled_draft = + fs::read_to_string(content_root.join(source_path)).expect("reconciled draft"); + assert!(reconciled_draft.contains("id: gmail-draft:created-1")); + assert!(reconciled_draft.ends_with("Body.\n")); assert!( store .find_virtual_mutation_by_path(&fixture.mount_id, source_path) .expect("find mutation") - .is_some() + .is_none() ); } #[test] -fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { +fn daemon_push_resumes_applied_gmail_draft_reconciliation_without_reapplying() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/reply.md"); @@ -1787,8 +1913,7 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { .expect("cache file"); let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let created_remote_id = RemoteId::new("gmail-message:sent-1"); + let created_remote_id = RemoteId::new("gmail-draft:created-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -1805,15 +1930,6 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { "draft", )) .expect("save draft 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, @@ -1836,7 +1952,7 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { PropertyValue::List(vec!["user@example.com".to_string()]), ); let plan = PushPlan::new( - vec![draft_folder_id], + vec![draft_folder_id.clone()], vec![PushOperation::CreateEntity { parent_id: RemoteId::new("gmail-folder:draft"), parent_kind: Some(EntityKind::Directory), @@ -1851,7 +1967,7 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { let effect = JournalApplyEffect::CreatedEntity { operation_id: PushOperationId("create-gmail-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id.clone(), + parent_id: draft_folder_id.clone(), entity_id: created_remote_id.clone(), }; store @@ -1869,7 +1985,7 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { let source = FakePushSource::default() .with_created_entity( created_remote_id.clone(), - rendered_entity("gmail-message:sent-1", "Body."), + rendered_entity("gmail-draft:created-1", "Body."), ) .with_apply_effects(vec![effect]); @@ -1886,22 +2002,27 @@ fn daemon_push_resumes_applied_gmail_send_reconciliation_without_reapplying() { .expect("retry applied gmail push"); assert_eq!(report.action, PushJobAction::Reconciled); - assert_eq!(source.applied_count(), 0, "retry must not resend Gmail"); + assert_eq!( + source.applied_count(), + 0, + "retry must not recreate the Gmail draft" + ); assert_eq!(report.push_id.as_ref(), Some(&push_id)); + assert!(source.applied_push_ids().is_empty()); 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()); + .expect("get draft") + .expect("draft entity"); + assert_eq!(message.path, PathBuf::from("draft/reply.md")); + assert!(content_root.join("draft/reply.md").exists()); + assert!(content_root.join(source_path).exists()); } #[test] -fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { +fn daemon_push_retries_gmail_draft_create_after_crash_before_effect_is_journaled() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/reply.md"); @@ -1915,7 +2036,7 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { .expect("cache file"); let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); + let created_remote_id = RemoteId::new("gmail-draft:created-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -1932,15 +2053,6 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { "draft", )) .expect("save draft folder"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - sent_folder_id, - EntityKind::Directory, - "sent", - "sent", - )) - .expect("save sent folder"); store .save_virtual_mutation(virtual_mutation( &fixture.mount_id, @@ -1984,7 +2096,20 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { JournalStatus::Applying, )) .expect("append applying journal"); - let source = FakePushSource::default(); + // The Gmail connector uses the deterministic Message-ID from this new + // attempt to find the draft that may have been created before the crash. + // The daemon must therefore allow it to reach the connector. + let source = FakePushSource::default() + .with_created_entity( + created_remote_id.clone(), + rendered_entity("gmail-draft:created-1", "Body."), + ) + .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { + operation_id: PushOperationId("create-gmail-draft".to_string()), + operation_index: 0, + parent_id: RemoteId::new("gmail-folder:draft"), + entity_id: created_remote_id.clone(), + }]); let report = execute_push_job_with_content_root( &mut store, @@ -1996,19 +2121,33 @@ fn daemon_push_blocks_ambiguous_gmail_send_journal_without_reapplying() { &source, Some(&state_root), ) - .expect("retry ambiguous gmail push"); + .expect("retry gmail draft create"); - 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")); + assert_eq!(report.action, PushJobAction::Reconciled); + assert_eq!( + source.applied_count(), + 1, + "retry must reach Gmail draft lookup" + ); + assert!(report.error.is_none()); + assert_ne!(report.push_id.as_ref(), Some(&push_id)); + assert_eq!(source.applied_push_ids(), vec![push_id.clone()]); + let created = store + .get_entity(&fixture.mount_id, &created_remote_id) + .expect("get created draft") + .expect("created draft entity"); + assert_eq!(created.path, PathBuf::from("draft/reply.md")); + assert!( + store + .list_journal() + .expect("journal") + .iter() + .any(|entry| entry.push_id == push_id && entry.status == JournalStatus::Applying) + ); } #[test] -fn daemon_push_blocks_failed_gmail_send_recovery_lookup_without_reapplying() { +fn daemon_push_retries_failed_gmail_draft_create_after_transport_failure() { let fixture = PushFixture::new(); let state_root = fixture.root.join(".state"); let source_path = Path::new("draft/reply.md"); @@ -2022,7 +2161,7 @@ fn daemon_push_blocks_failed_gmail_send_recovery_lookup_without_reapplying() { .expect("cache file"); let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); + let created_remote_id = RemoteId::new("gmail-draft:created-1"); let mut store = InMemoryStateStore::new(); store .save_mount( @@ -2039,15 +2178,6 @@ fn daemon_push_blocks_failed_gmail_send_recovery_lookup_without_reapplying() { "draft", )) .expect("save draft folder"); - store - .save_entity(EntityRecord::new( - fixture.mount_id.clone(), - sent_folder_id, - EntityKind::Directory, - "sent", - "sent", - )) - .expect("save sent folder"); store .save_virtual_mutation(virtual_mutation( &fixture.mount_id, @@ -2081,191 +2211,57 @@ fn daemon_push_blocks_failed_gmail_send_recovery_lookup_without_reapplying() { source_path: source_path.to_path_buf(), }], ); - let push_id = PushId("push-failed-gmail-send-lookup".to_string()); + let push_id = PushId("push-failed-gmail-draft-create".to_string()); store .append_journal(JournalEntry::new( push_id.clone(), fixture.mount_id.clone(), plan.affected_entities.clone(), plan, - JournalStatus::Failed( - "io error: gmail draft send ambiguous after send failure; sent lookup failed: sent search timed out" - .to_string(), - ), + JournalStatus::Failed("io error: Gmail draft create connection reset".to_string()), )) .expect("append failed 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 failed 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)); - let error = report.error.expect("guardrail error"); - assert_eq!(error.code, "guardrail"); - assert!(error.message.contains("ambiguous result")); -} - -#[test] -fn daemon_push_reconciles_repeated_gmail_draft_filename_to_unique_sent_paths() { - let fixture = PushFixture::new(); - let state_root = fixture.root.join(".state"); - let source_path = Path::new("draft/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 one.\n", - ) - .expect("cache file"); - - let draft_folder_id = RemoteId::new("gmail-folder:draft"); - let sent_folder_id = RemoteId::new("gmail-folder:sent"); - let first_remote_id = RemoteId::new("gmail-message:sent-1"); - let second_remote_id = RemoteId::new("gmail-message:sent-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(), - sent_folder_id.clone(), - EntityKind::Directory, - "sent", - "sent", - )) - .expect("save sent folder"); - store - .save_virtual_mutation(virtual_mutation( - &fixture.mount_id, - "local:gmail-draft-1", - VirtualMutationKind::Create, - None, - Some(draft_folder_id.clone()), - "draft/reply.md", - Some(cache_path.clone()), - )) - .expect("save first mutation"); - let first_source = FakePushSource::default() + let source = FakePushSource::default() .with_created_entity( - first_remote_id.clone(), - rendered_gmail_entity( - "gmail-message:sent-1", - "Reply", - "1720900000000", - "Body one.", - ), + created_remote_id.clone(), + rendered_entity("gmail-draft:created-1", "Body."), ) .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-gmail-draft-1".to_string()), + operation_id: PushOperationId("create-gmail-draft".to_string()), operation_index: 0, - parent_id: sent_folder_id.clone(), - entity_id: first_remote_id.clone(), + parent_id: RemoteId::new("gmail-folder:draft"), + entity_id: created_remote_id, }]); - let first = execute_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: true, confirm_dangerous: false, }, - &first_source, + &source, Some(&state_root), ) - .expect("first push"); + .expect("retry failed gmail draft create"); - assert_eq!(first.action, PushJobAction::Reconciled); - let first_message = store - .get_entity(&fixture.mount_id, &first_remote_id) - .expect("get first sent message") - .expect("first sent message"); + assert_eq!(report.action, PushJobAction::Reconciled); assert_eq!( - first_message.path, - PathBuf::from("sent/1720900000000-reply-gmail-message-sent-1.md") + source.applied_count(), + 1, + "retry must reach Gmail draft lookup" ); - - fs::write( - &cache_path, - "---\ntitle: Reply\nto: [\"user@example.com\"]\nsubject: Reply\n---\nBody two.\n", - ) - .expect("second cache file"); - store - .save_virtual_mutation(virtual_mutation( - &fixture.mount_id, - "local:gmail-draft-2", - VirtualMutationKind::Create, - None, - Some(draft_folder_id), - "draft/reply.md", - Some(cache_path), - )) - .expect("save second mutation"); - let second_source = FakePushSource::default() - .with_created_entity( - second_remote_id.clone(), - rendered_gmail_entity( - "gmail-message:sent-2", - "Reply", - "1720900001000", - "Body two.", - ), - ) - .with_apply_effects(vec![JournalApplyEffect::CreatedEntity { - operation_id: PushOperationId("create-gmail-draft-2".to_string()), - operation_index: 0, - parent_id: sent_folder_id, - entity_id: second_remote_id.clone(), - }]); - - let second = execute_push_job_with_content_root( - &mut store, - PushJob { - target_path: fixture.root.join(source_path), - assume_yes: true, - confirm_dangerous: false, - }, - &second_source, - Some(&state_root), - ) - .expect("second push"); - - assert_eq!(second.action, PushJobAction::Reconciled); - let second_message = store - .get_entity(&fixture.mount_id, &second_remote_id) - .expect("get second sent message") - .expect("second sent message"); - assert_eq!( - second_message.path, - PathBuf::from("sent/1720900001000-reply-gmail-message-sent-2.md") + assert!(report.error.is_none()); + assert_ne!(report.push_id.as_ref(), Some(&push_id)); + assert_eq!(source.applied_push_ids(), vec![push_id.clone()]); + assert!( + store + .list_journal() + .expect("journal") + .iter() + .any(|entry| entry.push_id == push_id + && matches!(entry.status, JournalStatus::Failed(_))) ); - assert!(content_root.join(first_message.path).exists()); - assert!(content_root.join(second_message.path).exists()); } #[test] @@ -3421,9 +3417,11 @@ impl FileWatcher for RecordingWatcher { #[derive(Default)] struct FakePushSource { + transition_remote_id: Option, remote_before_apply: Option, remote_after_apply: Option, applied: std::cell::Cell, + applied_push_ids: std::cell::RefCell>, requested_paths: std::cell::RefCell>, supported_operations: Option>, created_entities: BTreeMap, @@ -3454,10 +3452,27 @@ impl FakePushSource { } } + fn with_remote_transition_for( + remote_id: RemoteId, + remote_before_apply: HydratedEntity, + remote_after_apply: HydratedEntity, + ) -> Self { + Self { + transition_remote_id: Some(remote_id), + remote_before_apply: Some(remote_before_apply), + remote_after_apply: Some(remote_after_apply), + ..Self::default() + } + } + fn applied_count(&self) -> usize { self.applied.get() } + fn applied_push_ids(&self) -> Vec { + self.applied_push_ids.borrow().clone() + } + fn requested_paths(&self) -> Vec { self.requested_paths.borrow().clone() } @@ -3537,7 +3552,11 @@ impl HydrationSource for FakePushSource { if let Some(rendered) = self.created_entities.get(&request.remote_id) { return Ok(rendered.clone()); } - if request.remote_id != RemoteId::new("page-1") { + let expected_remote_id = self + .transition_remote_id + .clone() + .unwrap_or_else(|| RemoteId::new("page-1")); + if request.remote_id != expected_remote_id { return Err(LocalityError::InvalidState( "unexpected remote id".to_string(), )); @@ -3599,6 +3618,9 @@ impl Connector for FakePushSource { fn apply(&self, request: ApplyPlanRequest<'_>) -> LocalityResult { self.applied.set(self.applied.get() + 1); + self.applied_push_ids + .borrow_mut() + .push(request.push_id.clone()); let changed_remote_ids = self.apply_changed_remote_ids.clone().unwrap_or_else(|| { if self.apply_effects.is_empty() { request.plan.affected_entities.clone() @@ -3633,23 +3655,24 @@ fn rendered_entity(remote_id: &str, plain_body: &str) -> HydratedEntity { } } -fn rendered_gmail_entity( - remote_id: &str, - subject: &str, - internal_date: &str, - plain_body: &str, -) -> HydratedEntity { - let body = markdown_body(plain_body); - let remote_version = format!("gmail:{remote_id}:{internal_date}:SENT"); - let document = CanonicalDocument::new( - format!( - "loc:\n id: {remote_id}\n type: page\n connector: gmail\n synced_at: {remote_version}\n remote_edited_at: {remote_version}\ntitle: {subject}\ngmail:\n mailbox: sent\n message_id: {remote_id}\n thread_id: thread-{remote_id}\n labels: [SENT]\nfrom: sender@example.com\nto: [user@example.com]\ncc: []\nbcc: []\nsubject: {subject}\ndate: Tue, 14 Jul 2026 10:00:00 +0000\n" - ), - body.clone(), +fn rendered_gmail_draft_entity(remote_id: &str, subject: &str, body: &str) -> HydratedEntity { + let remote_version = + "gmail-draft:draft-1:gmail:draft-message-1:1720900000000:DRAFT".to_string(); + let frontmatter = format!( + "loc:\n id: \"{remote_id}\"\n type: page\n connector: gmail\n synced_at: \"{remote_version}\"\n remote_edited_at: \"{remote_version}\"\ntitle: \"{subject}\"\ngmail:\n mailbox: \"draft\"\n draft_id: \"draft-1\"\n message_id: \"draft-message-1\"\n internal_date: \"1720900000000\"\n thread_id: \"thread-1\"\n rfc_message_id: \"\"\n reply_to: \"\"\n in_reply_to: \"\"\n references: [\"\"]\n labels: [\"DRAFT\"]\n attachments: []\nfrom: \"\"\nto: [\"user@example.com\"]\ncc: []\nbcc: []\nsubject: \"{subject}\"\ndate: \"\"\n" ); + let document = CanonicalDocument::new(frontmatter.clone(), body); + let shadow = ShadowDocument::from_synced_body( + RemoteId::new(remote_id), + body, + 1, + [RemoteId::new("gmail-draft-body-1")], + ) + .expect("Gmail draft shadow") + .with_frontmatter(frontmatter); HydratedEntity { document, - shadow: shadow(remote_id, plain_body), + shadow, remote_edited_at: Some(remote_version), assets: Vec::new(), } diff --git a/crates/localityd/tests/source_descriptor.rs b/crates/localityd/tests/source_descriptor.rs index d4c35227..48114c7c 100644 --- a/crates/localityd/tests/source_descriptor.rs +++ b/crates/localityd/tests/source_descriptor.rs @@ -5,7 +5,9 @@ use locality_core::model::{EntityKind, MountId, RemoteId}; use locality_core::push::BodyDiffMode; use locality_core::shadow::ShadowDocument; use locality_core::validation::ValidationIssue; -use locality_gmail::{GMAIL_CONNECTOR_ID, GMAIL_OAUTH_SCOPES, StoredGmailCredential}; +use locality_gmail::{ + GMAIL_CONNECTOR_ID, GMAIL_OAUTH_SCOPES, GmailMountSettings, StoredGmailCredential, +}; use locality_google_calendar::{ GOOGLE_CALENDAR_CONNECTOR_ID, GOOGLE_CALENDAR_OAUTH_SCOPES, StoredGoogleCalendarCredential, }; @@ -22,8 +24,8 @@ use locality_store::{ use localityd::source::{ LocalSourceValidator, ResolvedSource, ResolvedSourceSet, SourcePushValidator, SourceValidationContext, VirtualRenamePolicy, resolve_source_for_mount, - source_create_decision_for_parent_path, source_descriptor, source_display_name, - source_move_decision_for_parent_path, source_write_decision_for_path, + source_create_decision_for_parent_path, source_delete_decision_for_path, source_descriptor, + source_display_name, source_move_decision_for_parent_path, source_write_decision_for_path, supported_source_connectors, }; use std::io::{Read, Write}; @@ -126,6 +128,28 @@ fn gmail_descriptor_comes_from_registry() { descriptor.create_entity_parent_kinds(), &[EntityKind::Directory] ); + assert_eq!(descriptor.body_diff_mode(), BodyDiffMode::WholeEntity); +} + +#[test] +fn gmail_drafts_are_writable_and_creatable_but_not_deletable() { + let mut mount = gmail_mount(); + mount.read_only = false; + + assert!( + source_write_decision_for_path(&mount, std::path::Path::new("draft/existing.md")) + .is_writable() + ); + assert!( + source_create_decision_for_parent_path(&mount, std::path::Path::new("draft")).is_writable() + ); + let deletion = + source_delete_decision_for_path(&mount, std::path::Path::new("draft/existing.md")); + assert!(!deletion.is_writable()); + assert_eq!( + deletion.reason(), + Some("Gmail draft deletion is not supported") + ); } #[test] @@ -804,6 +828,11 @@ fn expired_gmail_credential(access_token: &str, broker_url: String) -> StoredGma fn gmail_mount() -> MountConfig { MountConfig::new(MountId::new("gmail-main"), GMAIL_CONNECTOR_ID, "/tmp/gmail") + .with_settings_json( + GmailMountSettings::default() + .to_json() + .expect("Gmail settings"), + ) } fn google_calendar_mount() -> MountConfig { @@ -839,8 +868,29 @@ fn validate_gmail_create(path: &str, markdown: &str) -> Vec { } fn validate_gmail_changed(path: &str, markdown: &str) -> Vec { + validate_gmail_changed_with_shadow(path, markdown, None) + .into_iter() + .map(|issue| issue.code) + .collect() +} + +fn validate_gmail_changed_with_shadow( + path: &str, + markdown: &str, + shadow_frontmatter: Option<&str>, +) -> Vec { let mount = gmail_mount(); let parsed = parse_canonical_markdown(markdown).expect("parse gmail markdown"); + let shadow = shadow_frontmatter.map(|frontmatter| { + ShadowDocument::from_synced_body( + RemoteId::new("gmail-draft:draft-1"), + "Body\n", + 1, + vec![RemoteId::new("gmail-draft:draft-1:body:0")], + ) + .expect("gmail shadow") + .with_frontmatter(frontmatter) + }); LocalSourceValidator .validate_changed_frontmatter(SourceValidationContext { @@ -849,13 +899,10 @@ fn validate_gmail_changed(path: &str, markdown: &str) -> Vec { parent: None, relative_path: std::path::Path::new(path), parsed: &parsed, - shadow: None, + shadow: shadow.as_ref(), }) .expect("validate gmail changed") .issues - .into_iter() - .map(|issue| issue.code) - .collect() } fn validate_google_calendar_create(path: &str, markdown: &str) -> Vec { @@ -1589,12 +1636,74 @@ fn resolving_gmail_mount_with_invalid_settings_reports_validation_detail() { let error = resolve_source_for_mount(&store, &credentials, &mount) .expect_err("invalid Gmail settings should reject resolver"); - assert_eq!(error.code(), "credential_store_unavailable"); + assert_eq!(error.code(), "connector_settings_invalid"); let message = error.message(); assert!(message.contains("Gmail mount `gmail-main` settings are invalid")); assert!(message.contains("Gmail mount settings JSON is invalid")); } +#[test] +fn resolving_gmail_mount_with_legacy_implicit_layout_fails_before_enumeration() { + let mut store = InMemoryStateStore::new(); + let credentials = InMemoryCredentialStore::new(); + let (connection_id, secret_ref) = + save_gmail_connection(&mut store, "gmail-default", GMAIL_CONNECTOR_ID, "oauth"); + credentials + .put( + &secret_ref, + &serde_json::to_string(&stored_gmail_credential("gmail-access-token")) + .expect("credential json"), + ) + .expect("save credential"); + let mount = gmail_mount() + .with_connection_id(connection_id) + .with_settings_json("{}"); + + let error = resolve_source_for_mount(&store, &credentials, &mount) + .expect_err("legacy implicit Gmail layout must not be reinterpreted"); + + assert_eq!(error.code(), "connector_settings_invalid"); + assert_eq!( + error.message(), + "Gmail mount `gmail-main` settings are invalid: Gmail mount uses legacy implicit settings (`{}`), whose message layout cannot be safely changed in place to the thread-default layout" + ); +} + +#[test] +fn resolving_gmail_mount_with_newer_projection_layout_requires_update() { + let mut store = InMemoryStateStore::new(); + let credentials = InMemoryCredentialStore::new(); + let (connection_id, secret_ref) = + save_gmail_connection(&mut store, "gmail-default", GMAIL_CONNECTOR_ID, "oauth"); + credentials + .put( + &secret_ref, + &serde_json::to_string(&stored_gmail_credential("gmail-access-token")) + .expect("credential json"), + ) + .expect("save credential"); + let mount = gmail_mount() + .with_connection_id(connection_id) + .with_settings_json(r#"{"gmail":{"view":"threads"},"projection_layout_version":3}"#); + + let error = resolve_source_for_mount(&store, &credentials, &mount) + .expect_err("newer Gmail projection layout must require an update"); + + assert_eq!(error.code(), "update_required"); + assert_eq!( + error.message(), + "update required for gmail:projection_layout: found version 3, supported version 2" + ); + assert_eq!( + locality_core::LocalityError::from(error), + locality_core::LocalityError::UpdateRequired { + component: "gmail:projection_layout".to_string(), + found: 3, + supported: 2, + } + ); +} + #[test] fn resolving_expired_gmail_credential_rejects_refresh_missing_required_scope() { let mut store = InMemoryStateStore::new(); @@ -2025,6 +2134,52 @@ fn local_gmail_validator_blocks_changed_inbox_and_sent_items() { } } +#[test] +fn local_gmail_validator_allows_remote_draft_content_and_editable_header_changes() { + let shadow = "loc:\n id: gmail-draft:draft-1\n type: page\n connector: gmail\n synced_at: old\n remote_edited_at: old\ntitle: Original\ngmail:\n mailbox: draft\n draft_id: draft-1\n message_id: message-1\n thread_id: thread-1\n rfc_message_id: \n in_reply_to: \n references: []\n labels: [DRAFT]\nfrom: sender@example.com\nto: [old@example.com]\ncc: []\nbcc: []\nsubject: Original\ndate: Tue, 14 Jul 2026 10:00:00 +0000\n"; + let markdown = "---\nloc:\n id: gmail-draft:draft-1\n type: page\n connector: gmail\n synced_at: new\n remote_edited_at: new\ntitle: Original\ngmail:\n mailbox: draft\n draft_id: draft-1\n message_id: message-1\n thread_id: thread-1\n rfc_message_id: \n in_reply_to: \n references: []\n labels: [DRAFT]\nfrom: sender@example.com\nto: [new@example.com]\ncc: [copy@example.com]\nbcc: [blind@example.com]\nsubject: Updated\ndate: Tue, 14 Jul 2026 10:00:00 +0000\n---\nUpdated body\n"; + + let issues = validate_gmail_changed_with_shadow("draft/reply.md", markdown, Some(shadow)); + + assert!(issues.is_empty(), "{issues:#?}"); +} + +#[test] +fn local_gmail_validator_rejects_remote_draft_identity_and_metadata_changes() { + let shadow = "loc:\n id: gmail-draft:draft-1\n type: page\n connector: gmail\ntitle: Original\ngmail:\n mailbox: draft\n draft_id: draft-1\n message_id: message-1\n thread_id: thread-1\n rfc_message_id: \n labels: [DRAFT]\nfrom: sender@example.com\nto: [old@example.com]\ncc: []\nbcc: []\nsubject: Original\ndate: Tue, 14 Jul 2026 10:00:00 +0000\n"; + let markdown = "---\nloc:\n id: gmail-draft:draft-2\n type: page\n connector: gmail\ntitle: Original\ngmail:\n mailbox: inbox\n draft_id: draft-2\n message_id: message-2\n thread_id: thread-1\n rfc_message_id: \n labels: [INBOX]\nfrom: attacker@example.com\nto: [old@example.com]\ncc: []\nbcc: []\nsubject: Original\ndate: Wed, 15 Jul 2026 10:00:00 +0000\n---\nBody\n"; + + let issues = validate_gmail_changed_with_shadow("draft/reply.md", markdown, Some(shadow)); + let codes = issues + .iter() + .map(|issue| issue.code.as_str()) + .collect::>(); + + assert_eq!(codes[0], "gmail_immutable_identity"); + assert_eq!( + codes + .iter() + .filter(|code| **code == "gmail_immutable_frontmatter") + .count(), + 3 + ); + assert!(issues.iter().any(|issue| { + issue + .message + .contains("Gmail frontmatter `gmail` is read-only") + })); +} + +#[test] +fn local_gmail_validator_blocks_changed_thread_projection() { + let issues = validate_gmail_changed( + "inbox/topic-thread/page.md", + "---\nloc:\n id: gmail-thread:inbox:thread-1\n type: page\n connector: gmail\ntitle: Topic\n---\nEdited body\n", + ); + + assert_eq!(issues, vec!["gmail_read_only_mailbox"]); +} + #[test] fn local_google_calendar_validator_allows_valid_direct_draft_create() { let issues = validate_google_calendar_create( diff --git a/docs/cli.md b/docs/cli.md index 4e53f155..d9d86615 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,6 +24,7 @@ The `loc` command is the single supported control surface for users and coding a - `loc locate ` - `loc create page --title [--parent <dir>] [--private] [--json]` - `loc create database --title <title> [--parent <page-dir>] [--json]` +- `loc create gmail-reply <thread-dir> [--message <message-file>] [--json]` - `loc templates list|validate|new|apply [args] [--json]` - `loc okf export <path> --out <dir> [--json]` - `loc inspect <path> [--json]` @@ -86,15 +87,29 @@ 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 <path>` 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 <id>`. When `--mount-id` is omitted, Locality uses `gmail-main` when available. Gmail mounts project `inbox/`, `sent/`, and `draft/` folders. `inbox/` and `sent/` are read-only; create a Markdown file directly under `draft/` to create an unsent Gmail UI draft on push. +`loc mount gmail <path>` 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 <id>`. When `--mount-id` is omitted, Locality uses `gmail-main` when available. Gmail mounts default to thread directories and project `inbox/`, `sent/`, and `draft/` folders. `inbox/` and `sent/` are read-only. `draft/` enumerates Gmail drafts and is the editable local surface for creating or updating unsent Gmail UI drafts. Gmail mount options: - `--after YYYY-MM-DD --before YYYY-MM-DD`: persist a Gmail date window for inbox and sent enumeration. The flags must be used together. -- `--view messages`: keep the default flat message-file projection. -- `--view threads`: project Gmail threads as page directories with child message - files. +- `--view threads`: use the default Gmail thread-directory projection, with child + message files. +- `--view messages`: use the flat message-file compatibility projection. + +New Gmail mounts persist an explicit projection layout version. Legacy Gmail +mounts whose settings are exactly `{}` used the former message-view default and +fail cleanly instead of being silently reinterpreted as a mixed message/thread +tree. Register the existing mount explicitly with `--view messages` to preserve +that layout, or use a new mount ID and root for thread view. + +`loc create gmail-reply <thread-dir>` creates a local reply draft from a hydrated +Gmail thread message. By default it selects the latest message file in the +thread; pass `--message <message-file>` to reply to a specific child message. +It writes the Gmail thread and RFC reply headers into a new file directly under +`draft/`, ready for review and `loc push`. The source message must have been +hydrated so its Gmail `Message-ID` metadata is available. `--message` accepts a +child filename, path, or Gmail message ID. `loc connect granola --api-key-stdin [--name <id>]` validates a Granola Business or Enterprise API key against the official public API and stores it in Locality's credential store. The default connection is `granola-default`; the connector profile is `granola-api-key-default` at semantic version `granola.v1`. The key is never written to SQLite or command output. @@ -641,10 +656,10 @@ one known entity and downloads its file-like media. Pull refuses to overwrite a hydrated file if its body no longer matches the Synced Tree shadow, returning a dirty skip instead. -For Gmail mounts, pull enumerates the recent 100 inbox messages and recent 100 -sent messages by default. Date-window mounts page through all matching inbox and -sent messages. `draft/` is present for local sends, but v1 does not enumerate -remote Gmail drafts. +For Gmail mounts, pull enumerates the recent 100 inbox threads, recent 100 sent +threads, and recent 100 Gmail drafts by default. Date-window mounts page through +all matching inbox and sent threads. Remote drafts are projected under `draft/` +and can be edited locally. 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. @@ -857,11 +872,12 @@ 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/`. Push creates an unsent Gmail draft; send it later from the Gmail UI. -Gmail draft files require `to` frontmatter and either `subject` or -`title`; `cc` and `bcc` are optional. Nested draft files and edits or deletes in -`inbox/` and `sent/` are rejected. +For Gmail, `loc push` creates new drafts and updates existing drafts directly +under `draft/`. Push only creates or updates unsent Gmail UI drafts; Locality +has no send endpoint, so sending remains a Gmail UI action. Gmail draft files +require `to` frontmatter and either `subject` or `title`; `cc` and `bcc` are +optional. Nested draft files, draft deletion, and edits or deletes in `inbox/` +and `sent/` are unsupported. Unsupported-operation JSON shape: diff --git a/docs/gmail-connector.md b/docs/gmail-connector.md index e478506f..54aece76 100644 --- a/docs/gmail-connector.md +++ b/docs/gmail-connector.md @@ -18,8 +18,8 @@ The connector projects a fixed mailbox shape: draft/ ``` -`inbox/` and `sent/` are read-only. `draft/` is the local write surface for -outbound mail. +`inbox/` and `sent/` are read-only. `draft/` mirrors Gmail drafts and is the +local write surface for unsent draft creation and edits. ## OAuth @@ -66,11 +66,10 @@ CLI overrides: ## Projection And Pull -By default, Pull enumerates the recent 100 inbox messages and recent 100 sent -messages and recent 100 Gmail drafts. The `draft/` folder is the local staging surface -for new Gmail drafts. When pushed, a local draft becomes an unsent Gmail draft -and is visible in the Gmail UI; drafts created in Gmail are pulled into this -folder too. +By default, Pull enumerates the recent 100 inbox threads, recent 100 sent +threads, and recent 100 Gmail drafts. The `draft/` folder contains both drafts +created in Gmail and local changes to those drafts. `loc push` creates or updates +an unsent Gmail UI draft; it never sends mail. Gmail mounts can be registered with a date window: @@ -84,7 +83,7 @@ Date-window mounts use Gmail search query dates and page through all matching messages for `inbox/` and `sent/` instead of stopping after the first recent 100 results. -Message view is the default projection: +Message view is available as an explicit compatibility projection: ```text gmail-main/ @@ -95,10 +94,10 @@ gmail-main/ draft/ ``` -Thread view is opt-in: +Thread view is the default: ```bash -./target/debug/loc mount gmail ~/Locality/gmail-main --view threads +./target/debug/loc mount gmail ~/Locality/gmail-main ``` Thread view projects thread pages and child messages: @@ -113,8 +112,36 @@ gmail-main/ draft/ ``` -Inbox, sent, and thread content is read-only. Creating a Markdown file directly -under `draft/` creates an unsent Gmail draft when pushed. +New mounts persist Gmail projection layout version `2` together with the +explicit `threads` view. A mount created by an older Locality version with +implicit `{}` settings used the old flat-message default; Locality refuses to +reinterpret that mount in place because doing so would leave old message files +beside new thread directories. Preserve that mount by registering it explicitly +with `--view messages`, or create a new mount ID and root for thread view after +reviewing any local work in the old mount. + +Inbox and sent content is read-only. Draft files are editable: creating a +Markdown file directly under `draft/` creates an unsent Gmail draft when pushed, +and editing a projected draft updates it. Locality has no send endpoint; send +the completed draft from the Gmail UI. + +To reply in an existing thread, create the draft from its hydrated thread +directory. Locality uses the latest child message by default, or the explicitly +selected message, and carries the Gmail thread ID plus RFC reply headers into +the draft: + +```bash +./target/debug/loc create gmail-reply \ + "$HOME/Locality/gmail-main/inbox/1720900000000-quarterly-update-thread-a" + +./target/debug/loc create gmail-reply \ + "$HOME/Locality/gmail-main/inbox/1720900000000-quarterly-update-thread-a" \ + --message 1720900000000-quarterly-update-msg-1.md +``` + +The selected message must be hydrated, because Locality needs its RFC +`Message-ID` metadata to produce a correctly threaded reply. The command writes +a new file directly under `draft/`; review it with `loc diff` before pushing. ## Attachments @@ -128,21 +155,23 @@ thread and writes them under: ``` Rendered message frontmatter includes attachment filename, MIME type, size, -Gmail attachment ID, and the local path. Draft sends still reject `attachment` -or `attachments` frontmatter; outbound attachments require a separate design. +Gmail attachment ID, and the local path. Draft creation rejects `attachment` or +`attachments` frontmatter. To avoid rewriting content Locality cannot preserve, +V1 updates are limited to simple `text/plain` drafts with no attachments, +multipart/HTML content, or custom MIME headers. Edit other drafts in Gmail. ## Write Policy `inbox/` and `sent/` are read-only. File Provider and source write policy should reject edits and deletes there. -Creating a Markdown file directly under `draft/` is writable: +Creating or editing a Markdown file directly under `draft/` is writable: ```text draft/reply.md ``` -Nested draft files are rejected: +Nested draft files are rejected, and draft deletion is unsupported: ```text draft/replies/reply.md @@ -162,9 +191,12 @@ subject: Follow up Thanks for the notes. I will follow up here. ``` -`loc push` for a Gmail draft creates an unsent Gmail draft. Send it from the -Gmail UI after review. Attachments are not supported for Gmail draft creation -in v1; `attachment` or `attachments` frontmatter is rejected. +`loc push` for a Gmail draft creates a new unsent Gmail draft or updates an +existing simple text-only draft. Locality has no send endpoint: send it from the +Gmail UI after review. Attachments are not supported for Gmail draft creation or +updates in v1; `attachment` or `attachments` frontmatter is rejected. HTML, +multipart, or custom-MIME drafts must be edited in Gmail so their content is not +lost. On macOS File Provider mounts, the push journal remembers the temporary local draft identifier before sending. Once Gmail apply and read-back both succeed, @@ -192,7 +224,7 @@ Force enumeration: ./target/debug/loc pull --json "$HOME/Locality/gmail-main" ``` -Review and create a Gmail UI draft: +Review and create or update a Gmail UI draft: ```bash ./target/debug/loc status "$HOME/Locality/gmail-main/draft/reply.md" From 87daf54c42d0c9d057fd9b2c12ad8a13350a749e Mon Sep 17 00:00:00 2001 From: Harsh Gupta <harsh@felvin.com> Date: Thu, 23 Jul 2026 13:20:54 -0700 Subject: [PATCH 4/5] Allow safe Gmail draft transport headers --- crates/locality-gmail/src/connector.rs | 27 ++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/locality-gmail/src/connector.rs b/crates/locality-gmail/src/connector.rs index 4c7fad4c..f790b5a8 100644 --- a/crates/locality-gmail/src/connector.rs +++ b/crates/locality-gmail/src/connector.rs @@ -850,8 +850,9 @@ fn is_simple_text_plain_draft_payload(payload: &GmailMessagePart) -> bool { payload.headers.iter().all(|header| { matches!( header.name.to_ascii_lowercase().as_str(), - "to" | "cc" + "received" | "to" | "cc" | "bcc" + | "reply-to" | "subject" | "in-reply-to" | "references" @@ -1804,7 +1805,7 @@ mod tests { use locality_core::push::RemotePrecondition; use locality_core::search::RAW_SEARCH_METADATA_KEY; - use super::{GmailConfig, GmailConnector}; + use super::{GmailConfig, GmailConnector, is_simple_text_plain_draft_payload}; use crate::client::GmailApi; use crate::dto::{ GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailMessage, GmailMessageList, @@ -2637,6 +2638,28 @@ mod tests { ); } + #[test] + fn draft_rewrite_allows_simple_text_plain_transport_and_reply_to_headers() { + let message: GmailMessage = serde_json::from_value(serde_json::json!({ + "id": "draft-msg-reply-to", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "Received", "value": "by 2002:a05:1234:: with SMTP id x; Thu, 23 Jul 2026 13:04:19 -0500" }, + { "name": "To", "value": "me@example.com" }, + { "name": "Reply-To", "value": "replies@example.com" }, + { "name": "Subject", "value": "Reply" } + ], + "body": { "data": "Qm9keQo" } + } + })) + .expect("simple reply-to draft"); + + assert!(is_simple_text_plain_draft_payload( + message.payload.as_ref().expect("payload") + )); + } + #[test] fn apply_rejects_remote_draft_drift_before_update() { let api = Arc::new(FakeGmailApi::default()); From 9107a5cac57d743e2e1e3c1624d19cbd5e252ced Mon Sep 17 00:00:00 2001 From: Harsh Gupta <harsh@felvin.com> Date: Fri, 24 Jul 2026 14:30:06 -0700 Subject: [PATCH 5/5] Drop date prefix from Gmail thread and message file names Thread directories and message/reply files now use <title_slug>_<id> instead of <date>-<title_slug>-<id>, dropping chronological sort order in favor of stable, readable names. --- crates/locality-gmail/src/connector.rs | 37 +++++++------------------- crates/localityd/src/gmail.rs | 2 +- docs/gmail-connector.md | 14 +++++----- 3 files changed, 17 insertions(+), 36 deletions(-) diff --git a/crates/locality-gmail/src/connector.rs b/crates/locality-gmail/src/connector.rs index f790b5a8..08c5b7bd 100644 --- a/crates/locality-gmail/src/connector.rs +++ b/crates/locality-gmail/src/connector.rs @@ -1467,13 +1467,7 @@ fn message_subject(message: &GmailMessage) -> String { } fn message_filename(message: &GmailMessage, title: &str) -> String { - let date = message.internal_date.as_deref().unwrap_or("unknown"); - format!( - "{}-{}-{}.md", - safe_slug(date), - safe_slug(title), - safe_slug(&message.id) - ) + format!("{}_{}.md", safe_slug(title), safe_slug(&message.id)) } fn draft_filename(draft: &GmailDraft, title: &str) -> String { @@ -1487,18 +1481,7 @@ fn draft_filename(draft: &GmailDraft, title: &str) -> String { } fn thread_directory_name(thread: &GmailThread, title: &str) -> String { - let date = thread - .messages - .iter() - .filter_map(|message| message.internal_date.as_deref()) - .min() - .unwrap_or("unknown"); - format!( - "{}-{}-{}", - safe_slug(date), - safe_slug(title), - safe_slug(&thread.id) - ) + format!("{}_{}", safe_slug(title), safe_slug(&thread.id)) } fn thread_starts_in_date_window(settings: &GmailMountSettings, thread: &GmailThread) -> bool { @@ -2016,7 +1999,7 @@ mod tests { .any(|entry| entry.remote_id == RemoteId::new("gmail-thread:inbox:thread-inbox-1")) ); assert!(entries.iter().any(|entry| entry.path - == std::path::PathBuf::from("inbox/1720900000000-hello-thread-inbox-1/page.md"))); + == std::path::PathBuf::from("inbox/hello_thread-inbox-1/page.md"))); assert!( entries .iter() @@ -2086,7 +2069,7 @@ mod tests { container: ChildContainer::PageChildren(RemoteId::new( "gmail-thread:inbox:thread-inbox-1", )), - parent_path: "inbox/1720900000000-hello-thread-inbox-1".into(), + parent_path: "inbox/hello_thread-inbox-1".into(), }) .expect("children"); @@ -2097,9 +2080,7 @@ mod tests { ); assert_eq!( result.entries[0].path, - std::path::PathBuf::from( - "inbox/1720900000000-hello-thread-inbox-1/1720900000000-hello-inbox-msg-1.md" - ) + std::path::PathBuf::from("inbox/hello_thread-inbox-1/hello_inbox-msg-1.md") ); } @@ -2117,7 +2098,7 @@ mod tests { container: ChildContainer::PageChildren(RemoteId::new( "gmail-thread:inbox:thread-shared", )), - parent_path: "inbox/1720900000000-hello-thread-shared".into(), + parent_path: "inbox/hello_thread-shared".into(), }) .expect("inbox children"); let sent_children = connector @@ -2126,7 +2107,7 @@ mod tests { container: ChildContainer::PageChildren(RemoteId::new( "gmail-thread:sent:thread-shared", )), - parent_path: "sent/1720900000000-hello-thread-shared".into(), + parent_path: "sent/hello_thread-shared".into(), }) .expect("sent children"); @@ -2212,7 +2193,7 @@ mod tests { assert_eq!(observation.title, "Hello"); assert_eq!( observation.projected_path, - std::path::PathBuf::from("inbox/1720900000000-hello-thread-inbox-1/page.md") + std::path::PathBuf::from("inbox/hello_thread-inbox-1/page.md") ); assert!(observation.raw_metadata_json.contains("thread-inbox-1")); let raw_metadata: serde_json::Value = @@ -2424,7 +2405,7 @@ mod tests { .expect("thread whose start is in range"); assert_eq!( included.path, - std::path::PathBuf::from("inbox/1782993600000-hello-thread-start-in-window/page.md") + std::path::PathBuf::from("inbox/hello_thread-start-in-window/page.md") ); assert!( included diff --git a/crates/localityd/src/gmail.rs b/crates/localityd/src/gmail.rs index 14c25bb3..a05053ec 100644 --- a/crates/localityd/src/gmail.rs +++ b/crates/localityd/src/gmail.rs @@ -789,7 +789,7 @@ mod tests { let request = HydrationRequest::new( MountId::new("gmail-main"), remote_id.clone(), - "inbox/thread-attach/1720900000000-attachments-msg-attach.md", + "inbox/thread-attach/attachments_msg-attach.md", HydrationState::Hydrated, HydrationReason::ExplicitPull, ); diff --git a/docs/gmail-connector.md b/docs/gmail-connector.md index 54aece76..c328aa12 100644 --- a/docs/gmail-connector.md +++ b/docs/gmail-connector.md @@ -88,9 +88,9 @@ Message view is available as an explicit compatibility projection: ```text gmail-main/ inbox/ - 1720900000000-quarterly-update-msg-1.md + quarterly-update_msg-1.md sent/ - 1720900100000-reply-msg-2.md + reply_msg-2.md draft/ ``` @@ -105,9 +105,9 @@ Thread view projects thread pages and child messages: ```text gmail-main/ inbox/ - 1720900000000-quarterly-update-thread-a/ + quarterly-update_thread-a/ page.md - 1720900000000-quarterly-update-msg-1.md + quarterly-update_msg-1.md sent/ draft/ ``` @@ -132,11 +132,11 @@ the draft: ```bash ./target/debug/loc create gmail-reply \ - "$HOME/Locality/gmail-main/inbox/1720900000000-quarterly-update-thread-a" + "$HOME/Locality/gmail-main/inbox/quarterly-update_thread-a" ./target/debug/loc create gmail-reply \ - "$HOME/Locality/gmail-main/inbox/1720900000000-quarterly-update-thread-a" \ - --message 1720900000000-quarterly-update-msg-1.md + "$HOME/Locality/gmail-main/inbox/quarterly-update_thread-a" \ + --message quarterly-update_msg-1.md ``` The selected message must be hydrated, because Locality needs its RFC