Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b8befa7
docs: add gmail remote draft plan
mohammedahmed18 Aug 4, 2026
fa2257f
feat(gmail): add draft API client methods
mohammedahmed18 Aug 4, 2026
215f28c
feat(gmail): project drafts with stable ids
mohammedahmed18 Aug 4, 2026
cab19bc
fixup! feat(gmail): project drafts with stable ids
mohammedahmed18 Aug 4, 2026
33811d4
feat(gmail): allow draft edit planning
mohammedahmed18 Aug 4, 2026
0720fbf
fix(gmail): validate changed outbound drafts
mohammedahmed18 Aug 4, 2026
131bb2e
test(localityd): pin gmail draft push plans
mohammedahmed18 Aug 4, 2026
18b9962
fixup! test(localityd): pin gmail draft push plans
mohammedahmed18 Aug 4, 2026
a89028b
feat: apply Gmail draft edits and sends
mohammedahmed18 Aug 4, 2026
b4c56f2
fixup! feat: apply Gmail draft edits and sends
mohammedahmed18 Aug 4, 2026
17fee38
fixup! feat: apply Gmail draft edits and sends
mohammedahmed18 Aug 4, 2026
c1e94f0
feat(gmail): reconcile draft edits and sends
mohammedahmed18 Aug 4, 2026
b0d0029
docs: clarify Gmail draft send guidance
mohammedahmed18 Aug 4, 2026
a8cbd80
test: extend live Gmail draft send roundtrip
mohammedahmed18 Aug 4, 2026
6d87a58
fix(gmail): verify live cleanup targets
mohammedahmed18 Aug 4, 2026
7dd467c
fix(gmail): use stable draft ids
mohammedahmed18 Aug 4, 2026
ff70239
fix: repair legacy Gmail draft ids
mohammedahmed18 Aug 4, 2026
af5d975
fix: retire legacy Gmail draft state
mohammedahmed18 Aug 4, 2026
3c91a98
fix: repair Gmail draft ids in virtual pulls
mohammedahmed18 Aug 4, 2026
536f0fb
fix: repair scheduled Gmail draft projections
mohammedahmed18 Aug 4, 2026
2c4b47a
fix: preserve edited Gmail draft stubs
mohammedahmed18 Aug 4, 2026
46c3158
fix: preserve scheduled Gmail draft stub drift
mohammedahmed18 Aug 4, 2026
12762f7
fix: preserve Gmail draft subject on send
mohammedahmed18 Aug 4, 2026
794e857
ci: run Gmail live send e2e
mohammedahmed18 Aug 4, 2026
d718f52
fix: prune stale Gmail draft projections
mohammedahmed18 Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/connector-live-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ jobs:
LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON: ${{ secrets.LOCALITY_GMAIL_LIVE_CREDENTIAL_JSON }}
LOCALITY_GMAIL_LIVE_TO_EMAIL: ${{ secrets.LOCALITY_GMAIL_LIVE_TO_EMAIL }}
LOCALITY_LIVE_GMAIL_VFS: "1"
LOCALITY_LIVE_GMAIL_SEND: "1"
LOCALITY_LIVE_FORCE_OAUTH_REFRESH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_oauth_refresh == true && '1' || '0' }}
LOCALITY_LIVE_ROTATED_CREDENTIAL_OUTPUT: ${{ github.event_name == 'workflow_dispatch' && inputs.persist_rotated_oauth_secrets == true && format('{0}/locality-gmail-live-credential.json', runner.temp) || '' }}
run: tests/live_gmail_vfs_roundtrip.sh
Expand Down
19 changes: 13 additions & 6 deletions apps/desktop/src-tauri/src/agent_guidance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ Connected sources can include Notion, Google Docs, Google Calendar, Gmail, Linea
- Unless the user asked you to apply edits remotely, leave edits pending for Locality review and tell the user what changed.
- If desktop Live Mode is on, safe local edits may sync automatically. Use `loc live-mode status <file>` to inspect state. Do not run routine `loc pull` or `loc push` after every edit.
- If the user asks you to sync, publish, send, update the source, or apply the edit remotely, run `loc diff <path>` first, then `loc push <path> -y` for safe plans.
- If Live Mode is paused, conflicted, or review-needed, inspect with `loc status <path>` and `loc diff <path>` before pushing.
- If push says the remote changed since last sync, run `loc pull <path>`, resolve any inline conflict markers in the Markdown, rerun `loc diff <path>`, then push again.

## Creating Notion Content
Expand All @@ -453,7 +454,9 @@ Connected sources can include Notion, Google Docs, Google Calendar, Gmail, Linea
- Notion pages are directories with `page.md`; child pages live as child directories. Preserve Locality identity frontmatter, block IDs, directives starting with `::loc{{`, `_schema.yaml`, `AGENTS.md`, and `CLAUDE.md` unless explicitly asked.
- Google Docs files are writable Markdown documents. Preserve Locality frontmatter and follow the mount-local `AGENTS.md` for supported formatting.
- Calendar mounts expose drafts for new events. Create and edit Calendar drafts only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff <path>` and explicit push when the user asks to publish.
- Gmail mounts expose `draft/` for unsent drafts and `outbox/` for direct sends. Use `outbox/` only when the user explicitly asks to send now; otherwise use `draft/`. Create and edit Gmail outbound files only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff <path>` and explicit push when the user asks to send.
- Gmail mounts expose `draft/` for remote Gmail drafts and local draft creates. Leave messages in `draft/` when the user asks to draft or revise.
- Use `outbox/` only when the user explicitly asks to send now. Moving an existing draft into `outbox/` sends that draft after applying local edits.
- Create and edit Gmail outbound files only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff <path>` and explicit push when the user asks to send.
- Linear issue edits and Linear status moves are supported through the mounted issue files when the mount-local `AGENTS.md` says so. Inspect with `loc diff <path>` before pushing.
- Slack and Granola mounts are read-only. Do not edit, create, delete, rename, or push files there.

Expand Down Expand Up @@ -1096,6 +1099,7 @@ mod tests {
"loc pull <path>",
"loc live-mode status <file>",
"loc connect <provider> --no-browser",
"If Live Mode is paused, conflicted, or review-needed",
] {
assert!(skill.contains(command), "missing command {command}");
}
Expand All @@ -1110,11 +1114,14 @@ mod tests {
assert!(skill.contains("If initial search gives no hits, refine the query and browse directory names before concluding context is unavailable"));
assert!(skill.contains("If useful results are outside a user-provided path or source scope, do not read them until the user permits it"));
assert!(skill.contains("Calendar mounts expose drafts for new events"));
assert!(skill.contains("Gmail mounts expose `draft/` for unsent drafts"));
assert!(skill.contains("`outbox/` for direct sends"));
assert!(skill.contains(
"Use `outbox/` only when the user explicitly asks to send now; otherwise use `draft/`."
));
let expected_gmail_guidance = "\
- Gmail mounts expose `draft/` for remote Gmail drafts and local draft creates. Leave messages in `draft/` when the user asks to draft or revise.
- Use `outbox/` only when the user explicitly asks to send now. Moving an existing draft into `outbox/` sends that draft after applying local edits.
- Create and edit Gmail outbound files only through the filesystem shape described in the mount-local `AGENTS.md`, then use `loc diff <path>` and explicit push when the user asks to send.";
assert!(
skill.contains(expected_gmail_guidance),
"missing exact Gmail guidance:\n{skill}"
);
assert!(skill.contains("Linear issue edits"));
assert!(skill.contains("Linear status moves"));
assert!(skill.contains("Slack and Granola mounts are read-only"));
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ function suggestedAgentPrompt(mountPath: string, connector: OnboardingConnectorI
case "google-calendar":
return `Use Locality to inspect my Google Calendar source. Open the files under ${mountPath}, review calendar events with normal file tools, and prepare new event drafts for Locality review before creating them.`;
case "gmail":
return `Use Locality to inspect my Gmail source. Open the files under ${mountPath}, search mail with normal file tools, use draft/ for unsent Gmail drafts, and use outbox/ only when I explicitly ask you to send mail. Leave outbound changes for Locality review.`;
return `Use Locality to inspect my Gmail source. Open the files under ${mountPath}, search mail with normal file tools, leave messages in draft/ when I ask to draft or revise, and use outbox/ only when I explicitly ask you to send now. Leave outbound changes for Locality review.`;
case "linear":
return `Use Locality to edit my Linear issues. Open the files under ${mountPath}, update issue Markdown and editable frontmatter, and leave changes pending for Locality review before pushing.`;
case "notion":
Expand Down Expand Up @@ -3980,7 +3980,7 @@ function AddSourceDialog({
{
id: "gmail",
name: "Gmail",
description: "Inbox and sent as readable files, draft/ for Gmail drafts, outbox/ for reviewed direct sends.",
description: "Inbox and sent as readable files, draft/ for Gmail drafts and local creates, outbox/ for local-only reviewed sends.",
status: sourceConnectorStatus(snapshot, "gmail"),
keywords: ["gmail", "mail", "email", "inbox", "drafts"],
mounted: sourceMounted(snapshot, "gmail"),
Expand Down Expand Up @@ -7841,7 +7841,7 @@ const onboardingConnectorCards: OnboardingConnectorCard[] = [
{
connector: "gmail",
title: "Gmail",
description: "Inbox and sent mail as local files, draft/ for Gmail drafts, outbox/ for reviewed sends.",
description: "Inbox and sent mail as local files, draft/ for Gmail drafts and local creates, outbox/ for local-only reviewed sends.",
},
{
connector: "granola",
Expand Down
13 changes: 9 additions & 4 deletions connectors/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@
},
"capabilities": {
"supports_block_updates": false,
"supports_entity_body_updates": false,
"supports_entity_body_updates": true,
"supports_databases": false,
"supports_oauth": true,
"supports_remote_observation": true,
Expand All @@ -281,14 +281,19 @@
"supports_undo": false,
"supports_batch_observation": false
},
"push_operations": ["create_entity"],
"push_operations": [
"create_entity",
"update_properties",
"update_entity_body",
"move_entity"
],
"membership_operations": [],
"projection": {
"source_root_create_parent_kind": null,
"create_entity_parent_kinds": ["directory"],
"move_entity_parent_kinds": ["directory"],
"body_diff_mode": "block",
"virtual_rename_policy": "filename_derived",
"body_diff_mode": "whole_entity",
"virtual_rename_policy": "preserve_canonical",
"periodic_discovery_seconds": null,
"max_background_discovery_workers": 4
},
Expand Down
2 changes: 1 addition & 1 deletion crates/loc-cli/tests/mount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ fn mount_writes_gmail_attachment_guidance() {
assert!(agents.contains("`filename`, `mime_type`, `size`, `attachment_id`, and `path`"));
assert!(agents.contains(".loc/gmail/attachments/..."));
assert!(agents.contains("use the frontmatter path exactly"));
assert!(agents.contains("Gmail draft creation does not support outbound attachments yet"));
assert!(agents.contains("Gmail outbound attachments are not supported yet"));
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions crates/loc-cli/tests/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,10 @@ impl ShadowRepository for BulkOnlyStatusStore {
) -> StoreResult<Option<ShadowSnapshotRecord>> {
self.inner.get_shadow_record(mount_id, entity_id)
}

fn delete_shadow(&mut self, mount_id: &MountId, entity_id: &RemoteId) -> StoreResult<()> {
self.inner.delete_shadow(mount_id, entity_id)
}
}

impl JournalRepository for BulkOnlyStatusStore {
Expand Down
148 changes: 145 additions & 3 deletions crates/locality-gmail/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ use serde::Serialize;
use serde::de::DeserializeOwned;

use crate::dto::{
GmailDraft, GmailDraftCreateRequest, GmailDraftSendRequest, GmailMessage, GmailMessageList,
GmailMessagePartBody, GmailMessageSendRequest, GmailThread, GmailThreadList,
GmailDraft, GmailDraftCreateRequest, GmailDraftList, GmailDraftSendRequest,
GmailDraftUpdateRequest, GmailMessage, GmailMessageList, GmailMessagePartBody,
GmailMessageSendRequest, GmailThread, GmailThreadList,
};

pub const DEFAULT_GMAIL_API_BASE_URL: &str = "https://gmail.googleapis.com/gmail/v1";
Expand Down Expand Up @@ -43,7 +44,19 @@ pub trait GmailApi: std::fmt::Debug + Send + Sync {
message_id: &str,
attachment_id: &str,
) -> LocalityResult<GmailMessagePartBody>;
fn list_drafts(
&self,
max_results: u32,
page_token: Option<&str>,
query: Option<&str>,
) -> LocalityResult<GmailDraftList>;
fn get_draft_full(&self, draft_id: &str) -> LocalityResult<GmailDraft>;
fn create_draft(&self, request: GmailDraftCreateRequest) -> LocalityResult<GmailDraft>;
fn update_draft(
&self,
draft_id: &str,
request: GmailDraftUpdateRequest,
) -> LocalityResult<GmailDraft>;
fn send_message(&self, request: GmailMessageSendRequest) -> LocalityResult<GmailMessage>;
fn send_draft(&self, request: GmailDraftSendRequest) -> LocalityResult<GmailMessage>;
}
Expand Down Expand Up @@ -111,6 +124,21 @@ impl HttpGmailApiClient {
context,
)
}

fn put_json_with_context<T, B>(&self, path: &str, body: &B, context: &str) -> LocalityResult<T>
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 {
Expand Down Expand Up @@ -199,10 +227,47 @@ impl GmailApi for HttpGmailApiClient {
)
}

fn list_drafts(
&self,
max_results: u32,
page_token: Option<&str>,
search_query: Option<&str>,
) -> LocalityResult<GmailDraftList> {
let mut params = vec![("maxResults".to_string(), max_results.to_string())];
if let Some(page_token) = page_token {
params.push(("pageToken".to_string(), page_token.to_string()));
}
if let Some(search_query) = search_query {
params.push(("q".to_string(), search_query.to_string()));
}
self.get_json("/users/me/drafts", params)
}

fn get_draft_full(&self, draft_id: &str) -> LocalityResult<GmailDraft> {
let draft_id = percent_encode_path_segment(draft_id);
self.get_json(
&format!("/users/me/drafts/{draft_id}"),
vec![("format".to_string(), "full".to_string())],
)
}

fn create_draft(&self, request: GmailDraftCreateRequest) -> LocalityResult<GmailDraft> {
self.post_json_with_context("/users/me/drafts", &request, "gmail draft create")
}

fn update_draft(
&self,
draft_id: &str,
request: GmailDraftUpdateRequest,
) -> LocalityResult<GmailDraft> {
let draft_id = percent_encode_path_segment(draft_id);
self.put_json_with_context(
&format!("/users/me/drafts/{draft_id}"),
&request,
"gmail draft update",
)
}

fn send_message(&self, request: GmailMessageSendRequest) -> LocalityResult<GmailMessage> {
self.post_json_with_context("/users/me/messages/send", &request, "gmail message send")
}
Expand Down Expand Up @@ -273,7 +338,7 @@ mod tests {

use locality_core::LocalityError;

use crate::dto::GmailMessageSendRequest;
use crate::dto::{GmailDraftUpdateRequest, GmailMessageSendRequest, GmailRawMessage};

use super::{GmailApi, HttpGmailApiClient};

Expand Down Expand Up @@ -399,6 +464,46 @@ mod tests {
assert!(!target.contains(' '), "{target}");
}

#[test]
fn list_drafts_calls_gmail_drafts_endpoint_with_max_results() {
let (base_url, request_rx, server) = spawn_response_server(
"HTTP/1.1 200 OK",
r#"{"drafts":[{"id":"draft-1","message":{"id":"msg-1","threadId":"thread-1"}}],"resultSizeEstimate":1}"#,
);
let client = HttpGmailApiClient::with_base_url("access-token", base_url);

let drafts = client
.list_drafts(100, None, None)
.expect("draft list response");

assert_eq!(drafts.drafts[0].id, "draft-1");
let request = request_rx.recv().expect("request line");
server.join().expect("server exits");
let target = request.split_whitespace().nth(1).expect("request target");
assert_eq!(target, "/users/me/drafts?maxResults=100");
}

#[test]
fn get_draft_full_calls_gmail_draft_endpoint_with_full_format() {
let (base_url, request_rx, server) = spawn_response_server(
"HTTP/1.1 200 OK",
r#"{"id":"draft/1 space","message":{"id":"msg-1","threadId":"thread-1"}}"#,
);
let client = HttpGmailApiClient::with_base_url("access-token", base_url);

let draft = client
.get_draft_full("draft/1 space")
.expect("draft response");

assert_eq!(draft.id, "draft/1 space");
let request = request_rx.recv().expect("request line");
server.join().expect("server exits");
let target = request.split_whitespace().nth(1).expect("request target");
assert_eq!(target, "/users/me/drafts/draft%2F1%20space?format=full");
assert!(!target.contains("draft/1"), "{target}");
assert!(!target.contains(' '), "{target}");
}

#[test]
fn http_errors_map_google_status_semantics() {
assert!(matches!(
Expand Down Expand Up @@ -500,6 +605,43 @@ mod tests {
);
}

#[test]
fn update_draft_puts_raw_body_to_gmail_draft_endpoint() {
let (base_url, request_rx, server) = spawn_response_server(
"HTTP/1.1 200 OK",
r#"{"id":"draft/1","message":{"id":"updated-msg-1","threadId":"thread-1"}}"#,
);
let client = HttpGmailApiClient::with_base_url("access-token", base_url);

let updated = client
.update_draft(
"draft/1",
GmailDraftUpdateRequest {
message: GmailRawMessage {
raw: "updated-raw".to_string(),
},
},
)
.expect("draft update response");

assert_eq!(updated.message.id, "updated-msg-1");
let request = request_rx.recv().expect("request");
server.join().expect("server exits");
assert!(
request.starts_with("PUT /users/me/drafts/draft%2F1 HTTP/1.1"),
"{request}"
);
let request_lowercase = request.to_ascii_lowercase();
assert!(
request_lowercase.contains("authorization: bearer access-token"),
"{request}"
);
assert!(
request.ends_with(r#"{"message":{"raw":"updated-raw"}}"#),
"{request}"
);
}

fn request_error_for_status(status_line: &'static str, body: &'static str) -> LocalityError {
let (base_url, request_rx, server) = spawn_response_server(status_line, body);
let client = HttpGmailApiClient::with_base_url("access-token", base_url);
Expand Down
Loading
Loading