diff --git a/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py b/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py index 0f98e5744..f3e3705ea 100644 --- a/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py +++ b/surfsense_backend/app/agents/new_chat/tools/gmail/update_draft.py @@ -32,6 +32,12 @@ def create_update_gmail_draft_tool( Use when the user asks to modify, edit, or add content to an existing email draft. This replaces the draft content with the new version. + The user will be able to review and edit the content before it is applied. + + If the user simply wants to "edit" a draft without specifying exact changes, + generate the body yourself using your best understanding of the conversation + context. The user will review and can freely edit the content in the approval + card before confirming. IMPORTANT: This tool is ONLY for Gmail drafts, NOT for Notion pages, calendar events, or any other content type. @@ -39,7 +45,8 @@ def create_update_gmail_draft_tool( Args: draft_subject_or_id: The exact subject line of the draft to update (as it appears in Gmail drafts). - body: The full updated body content for the draft. + body: The full updated body content for the draft. Generate this + yourself based on the user's request and conversation context. to: Optional new recipient email address (keeps original if omitted). subject: Optional new subject line (keeps original if omitted). cc: Optional CC recipient(s), comma-separated. @@ -62,6 +69,7 @@ def create_update_gmail_draft_tool( Examples: - "Update the Kurseong Plan draft with the new itinerary details" - "Edit my draft about the project proposal and change the recipient" + - "Let me edit the meeting notes draft" (call with current body content so user can edit in the approval card) """ logger.info( f"update_gmail_draft called: draft_subject_or_id='{draft_subject_or_id}'" diff --git a/surfsense_backend/app/services/gmail/tool_metadata_service.py b/surfsense_backend/app/services/gmail/tool_metadata_service.py index 8a5c2955f..8d155be59 100644 --- a/surfsense_backend/app/services/gmail/tool_metadata_service.py +++ b/surfsense_backend/app/services/gmail/tool_metadata_service.py @@ -2,10 +2,11 @@ import asyncio import logging from dataclasses import dataclass from datetime import datetime +from typing import Any from google.oauth2.credentials import Credentials from googleapiclient.discovery import build -from sqlalchemy import and_, func, or_ +from sqlalchemy import String, and_, cast, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm.attributes import flag_modified @@ -264,7 +265,7 @@ class GmailToolMetadataService: if auth_expired: await self._persist_auth_expired(connector.id) - result = { + result: dict = { "account": acc_dict, "email": message.to_dict(), } @@ -273,8 +274,111 @@ class GmailToolMetadataService: if meta.get("draft_id"): result["draft_id"] = meta["draft_id"] + if not auth_expired: + existing_body = await self._fetch_draft_body( + connector, message.message_id, meta.get("draft_id") + ) + if existing_body is not None: + result["existing_body"] = existing_body + return result + async def _fetch_draft_body( + self, + connector: SearchSourceConnector, + message_id: str, + draft_id: str | None, + ) -> str | None: + """Fetch the plain-text body of a Gmail draft via the API. + + Tries ``drafts.get`` first (if *draft_id* is available), then falls + back to scanning ``drafts.list`` to resolve the draft by *message_id*. + Returns ``None`` on any failure so callers can degrade gracefully. + """ + try: + creds = await self._build_credentials(connector) + service = build("gmail", "v1", credentials=creds) + + if not draft_id: + draft_id = await self._find_draft_id(service, message_id) + if not draft_id: + return None + + draft = await asyncio.get_event_loop().run_in_executor( + None, + lambda: service.users() + .drafts() + .get(userId="me", id=draft_id, format="full") + .execute(), + ) + + payload = draft.get("message", {}).get("payload", {}) + return self._extract_body_from_payload(payload) + except Exception: + logger.warning( + "Failed to fetch draft body for message_id=%s", + message_id, + exc_info=True, + ) + return None + + async def _find_draft_id(self, service: Any, message_id: str) -> str | None: + """Resolve a draft ID from its message ID by scanning drafts.list.""" + try: + page_token = None + while True: + kwargs: dict[str, Any] = {"userId": "me", "maxResults": 100} + if page_token: + kwargs["pageToken"] = page_token + response = await asyncio.get_event_loop().run_in_executor( + None, + lambda: service.users().drafts().list(**kwargs).execute(), + ) + for draft in response.get("drafts", []): + if draft.get("message", {}).get("id") == message_id: + return draft["id"] + page_token = response.get("nextPageToken") + if not page_token: + break + return None + except Exception: + logger.warning( + "Failed to look up draft by message_id=%s", message_id, exc_info=True + ) + return None + + @staticmethod + def _extract_body_from_payload(payload: dict) -> str | None: + """Extract the plain-text (or html→text) body from a Gmail payload.""" + import base64 + + def _get_parts(p: dict) -> list[dict]: + if "parts" in p: + parts: list[dict] = [] + for sub in p["parts"]: + parts.extend(_get_parts(sub)) + return parts + return [p] + + parts = _get_parts(payload) + text_content = "" + for part in parts: + mime_type = part.get("mimeType", "") + data = part.get("body", {}).get("data", "") + if mime_type == "text/plain" and data: + text_content += base64.urlsafe_b64decode(data + "===").decode( + "utf-8", errors="ignore" + ) + elif mime_type == "text/html" and data and not text_content: + from markdownify import markdownify as md + + raw_html = base64.urlsafe_b64decode(data + "===").decode( + "utf-8", errors="ignore" + ) + text_content = md(raw_html).strip() + + return text_content.strip() if text_content.strip() else None + async def get_trash_context( self, search_space_id: int, user_id: str, email_ref: str ) -> dict: @@ -325,7 +429,7 @@ class GmailToolMetadataService: SearchSourceConnector.user_id == user_id, or_( func.lower( - Document.document_metadata["subject"].astext + cast(Document.document_metadata["subject"], String) ) == func.lower(email_ref), func.lower(Document.title) == func.lower(email_ref), diff --git a/surfsense_backend/app/services/google_calendar/tool_metadata_service.py b/surfsense_backend/app/services/google_calendar/tool_metadata_service.py index f2a8b08c3..17f6e8e67 100644 --- a/surfsense_backend/app/services/google_calendar/tool_metadata_service.py +++ b/surfsense_backend/app/services/google_calendar/tool_metadata_service.py @@ -5,7 +5,7 @@ from datetime import datetime from google.oauth2.credentials import Credentials from googleapiclient.discovery import build -from sqlalchemy import and_, func, or_ +from sqlalchemy import String, and_, cast, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm.attributes import flag_modified @@ -389,7 +389,7 @@ class GoogleCalendarToolMetadataService: SearchSourceConnector.user_id == user_id, or_( func.lower( - Document.document_metadata["event_summary"].astext + cast(Document.document_metadata["event_summary"], String) ) == func.lower(event_ref), func.lower(Document.title) == func.lower(event_ref), diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 74f415c0b..a3a9d4260 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -175,6 +175,7 @@ const TOOLS_WITH_UI = new Set([ "update_calendar_event", "delete_calendar_event", "create_gmail_draft", + "update_gmail_draft", "send_gmail_email", "trash_gmail_email", "execute", diff --git a/surfsense_web/components/tool-ui/gmail/send-email.tsx b/surfsense_web/components/tool-ui/gmail/send-email.tsx index cd4725c5a..4dbeedee0 100644 --- a/surfsense_web/components/tool-ui/gmail/send-email.tsx +++ b/surfsense_web/components/tool-ui/gmail/send-email.tsx @@ -5,7 +5,6 @@ import { CornerDownLeftIcon, MailIcon, Pen, - SendIcon, UserIcon, UsersIcon, } from "lucide-react"; @@ -193,7 +192,6 @@ function ApprovalCard({ {/* Header */}
-

{decided === "reject" diff --git a/surfsense_web/components/tool-ui/gmail/update-draft.tsx b/surfsense_web/components/tool-ui/gmail/update-draft.tsx index 15c72852e..9cb04f73f 100644 --- a/surfsense_web/components/tool-ui/gmail/update-draft.tsx +++ b/surfsense_web/components/tool-ui/gmail/update-draft.tsx @@ -49,6 +49,7 @@ interface InterruptResult { account?: GmailAccount; email?: GmailMessage; draft_id?: string; + existing_body?: string; error?: string; }; } @@ -176,6 +177,7 @@ function ApprovalCard({ const account = interruptData.context?.account; const email = interruptData.context?.email; const draftId = interruptData.context?.draft_id; + const existingBody = interruptData.context?.existing_body; const reviewConfig = interruptData.review_configs[0]; const allowedDecisions = reviewConfig?.allowed_decisions ?? [ @@ -193,6 +195,7 @@ function ApprovalCard({ const currentTo = pendingEdits?.to ?? args.to ?? ""; const currentCc = pendingEdits?.cc ?? args.cc ?? ""; const currentBcc = pendingEdits?.bcc ?? args.bcc ?? ""; + const editableBody = currentBody || existingBody || ""; const handleApprove = useCallback(() => { if (decided || isPanelOpen) return; @@ -208,7 +211,7 @@ function ApprovalCard({ draft_id: draftId, to: currentTo, subject: currentSubject, - body: currentBody, + body: editableBody, cc: currentCc, bcc: currentBcc, connector_id: email?.connector_id ?? account?.id, @@ -226,7 +229,7 @@ function ApprovalCard({ draftId, pendingEdits, currentSubject, - currentBody, + editableBody, currentTo, currentCc, currentBcc, @@ -308,10 +311,10 @@ function ApprovalCard({ value: currentBcc, }, ]; - openHitlEditPanel({ - title: currentSubject, - content: currentBody, - toolName: "Gmail Draft", + openHitlEditPanel({ + title: currentSubject, + content: editableBody, + toolName: "Gmail Draft", extraFields, onSave: ( newTitle, @@ -410,8 +413,8 @@ function ApprovalCard({ {currentSubject}

)} - {currentBody != null && ( -
- )} + ) : null}
{/* Action buttons */}