From 6144ab7c16f0f120fbfd93ac6ff8afc1c50e55eb Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 24 Aug 2026 20:37:15 -0700 Subject: [PATCH] feat: add send-only Gmail integration with draft_email chat tool Connect Gmail from Settings -> Integrations (loopback PKCE OAuth, gmail.send scope only) and ask the note chat to draft a follow-up email: a new draft_email tool composes from the note, transcript, and participants, and renders an editable email card in chat. Sending only happens when the user presses Send on the card. - Gmail OAuth + manager in the main process (net.fetch, no new deps), reusing the shared loopback flow; falls back to the calendar OAuth client - Gmail tokens encrypted at rest via secretCrypto (keychain AES-256-GCM), single-account, revoked on account deletion - draft_email tool gated on a connected Gmail account; participants from calendar events now included in the note chat context - EmailDraftCard with editable To/Cc/Subject/body, send states, and sent status persisted into the message's tool-call metadata - Forward AI-SDK tool outputs as metadata on the BYOK/local path so tool-result cards (including existing note cards) render there too - Integrations tile hidden when no OAuth client is configured; i18n for all 10 locales --- .env.example | 10 ++ main.js | 4 + preload.js | 14 ++ src/assets/icons/gmail.svg | 7 + src/components/IntegrationsView.tsx | 82 +++++++++ src/components/chat/ChatMessage.tsx | 6 + src/components/chat/EmailDraftCard.tsx | 211 ++++++++++++++++++++++++ src/components/chat/emailDrafts.ts | 41 +++++ src/components/chat/toolIcons.ts | 12 +- src/components/chat/useChatStreaming.ts | 4 +- src/components/notes/NoteEditor.tsx | 1 + src/config/prompts.ts | 2 + src/helpers/database.js | 137 +++++++++++++++ src/helpers/gmailManager.js | 115 +++++++++++++ src/helpers/gmailOAuth.js | 164 ++++++++++++++++++ src/helpers/ipcHandlers.js | 49 ++++++ src/hooks/useEmbeddedChat.ts | 44 +++-- src/locales/de/translation.json | 31 +++- src/locales/en/translation.json | 31 +++- src/locales/es/translation.json | 31 +++- src/locales/fr/translation.json | 31 +++- src/locales/it/translation.json | 31 +++- src/locales/ja/translation.json | 31 +++- src/locales/pt/translation.json | 31 +++- src/locales/ru/translation.json | 31 +++- src/locales/zh-CN/translation.json | 31 +++- src/locales/zh-TW/translation.json | 31 +++- src/services/ReasoningService.ts | 7 + src/services/tools/draftEmailTool.ts | 58 +++++++ src/services/tools/index.ts | 7 + src/stores/settingsStore.ts | 13 ++ src/types/electron.ts | 22 +++ test/helpers/gmailDatabase.test.js | 178 ++++++++++++++++++++ test/helpers/gmailManager.test.js | 194 ++++++++++++++++++++++ test/services/draftEmailTool.test.js | 49 ++++++ 35 files changed, 1706 insertions(+), 35 deletions(-) create mode 100644 src/assets/icons/gmail.svg create mode 100644 src/components/chat/EmailDraftCard.tsx create mode 100644 src/components/chat/emailDrafts.ts create mode 100644 src/helpers/gmailManager.js create mode 100644 src/helpers/gmailOAuth.js create mode 100644 src/services/tools/draftEmailTool.ts create mode 100644 test/helpers/gmailDatabase.test.js create mode 100644 test/helpers/gmailManager.test.js create mode 100644 test/services/draftEmailTool.test.js diff --git a/.env.example b/.env.example index 7a3712042f..0bb35d95fb 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,13 @@ MISTRAL_API_KEY=your_mistral_api_key_here # Optional: Debug mode OPENWHISPR_LOG_LEVEL=debug + +# Google Calendar integration OAuth client (optional) +# Create a Desktop-app OAuth client in Google Cloud Console +GOOGLE_CALENDAR_CLIENT_ID= +GOOGLE_CALENDAR_CLIENT_SECRET= + +# Gmail integration OAuth client (optional) +# Falls back to GOOGLE_CALENDAR_CLIENT_ID/_SECRET when unset (same GCP client) +GMAIL_CLIENT_ID= +GMAIL_CLIENT_SECRET= diff --git a/main.js b/main.js index e2b5b16572..607c882f41 100644 --- a/main.js +++ b/main.js @@ -289,6 +289,7 @@ const GoogleCalendarManager = require("./src/helpers/googleCalendarManager"); const MicrosoftCalendarManager = require("./src/helpers/microsoftCalendarManager"); const AppleCalendarManager = require("./src/helpers/appleCalendarManager"); const CalendarReminderScheduler = require("./src/helpers/calendarReminderScheduler"); +const GmailManager = require("./src/helpers/gmailManager"); const MeetingProcessDetector = require("./src/helpers/meetingProcessDetector"); const AudioActivityDetector = require("./src/helpers/audioActivityDetector"); const { @@ -329,6 +330,7 @@ let googleCalendarManager = null; let microsoftCalendarManager = null; let appleCalendarManager = null; let calendarReminderScheduler = null; +let gmailManager = null; let meetingDetectionEngine = null; let audioTapManager = null; let linuxPortalAudioManager = null; @@ -464,6 +466,7 @@ function initializeCoreManagers() { calendarReminderScheduler ); appleCalendarManager = new AppleCalendarManager(databaseManager, calendarReminderScheduler); + gmailManager = new GmailManager(databaseManager); meetingDetectionEngine = new MeetingDetectionEngine( calendarReminderScheduler, new MeetingProcessDetector(), @@ -522,6 +525,7 @@ function initializeCoreManagers() { googleCalendarManager, microsoftCalendarManager, appleCalendarManager, + gmailManager, meetingDetectionEngine, audioTapManager, linuxPortalAudioManager, diff --git a/preload.js b/preload.js index 2861dad055..73c089227c 100644 --- a/preload.js +++ b/preload.js @@ -989,6 +989,8 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("db-update-agent-conversation-title", id, title), addAgentMessage: (conversationId, role, content, metadata) => ipcRenderer.invoke("db-add-agent-message", conversationId, role, content, metadata), + updateAgentToolCall: (toolCallId, patch) => + ipcRenderer.invoke("db-update-agent-tool-call", toolCallId, patch), getAgentMessages: (conversationId) => ipcRenderer.invoke("db-get-agent-messages", conversationId), getAgentConversationsWithPreview: (limit, offset, includeArchived) => ipcRenderer.invoke("db-get-agent-conversations-with-preview", limit, offset, includeArchived), @@ -1150,6 +1152,12 @@ contextBridge.exposeInMainWorld("electronAPI", { acalGetConnectionStatus: () => ipcRenderer.invoke("acal-get-connection-status"), openCalendarPrivacySettings: () => ipcRenderer.invoke("open-calendar-privacy-settings"), + // Gmail (send-only) + gmailStartOAuth: () => ipcRenderer.invoke("gmail-start-oauth"), + gmailDisconnect: () => ipcRenderer.invoke("gmail-disconnect"), + gmailGetConnectionStatus: () => ipcRenderer.invoke("gmail-get-connection-status"), + gmailSendEmail: (draft) => ipcRenderer.invoke("gmail-send-email", draft), + // Contacts searchContacts: (query) => ipcRenderer.invoke("search-contacts", query), upsertContact: (contact) => ipcRenderer.invoke("upsert-contact", contact), @@ -1185,6 +1193,12 @@ contextBridge.exposeInMainWorld("electronAPI", { (callback) => (_event, data) => callback(data) ), + // Gmail event listeners + onGmailConnectionChanged: registerListener( + "gmail-connection-changed", + (callback) => (_event, data) => callback(data) + ), + // Meeting detection meetingDetectionGetPreferences: () => ipcRenderer.invoke("meeting-detection-get-preferences"), meetingDetectionSetPreferences: (prefs) => diff --git a/src/assets/icons/gmail.svg b/src/assets/icons/gmail.svg new file mode 100644 index 0000000000..88536fdb80 --- /dev/null +++ b/src/assets/icons/gmail.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components/IntegrationsView.tsx b/src/components/IntegrationsView.tsx index de3422e314..2c9a0f11c0 100644 --- a/src/components/IntegrationsView.tsx +++ b/src/components/IntegrationsView.tsx @@ -24,6 +24,7 @@ import McpIntegrationCard from "./McpIntegrationCard"; import googleCalendarIcon from "../assets/icons/google-calendar.svg"; import microsoftCalendarIcon from "../assets/icons/microsoft-calendar.svg"; import appleCalendarIcon from "../assets/icons/apple-calendar.svg"; +import gmailIcon from "../assets/icons/gmail.svg"; const API_DOCS_URL = "https://docs.openwhispr.com/api/overview"; @@ -171,6 +172,9 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView setMcalPrimaryOnly, appleCalendarConnected, setAppleCalendarConnected, + gmailConnected, + gmailEmail, + setGmailConnection, } = useSettingsStore(); const [isConnecting, setIsConnecting] = useState(false); const [disconnectingEmail, setDisconnectingEmail] = useState(null); @@ -183,6 +187,11 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView const [appleSourceNames, setAppleSourceNames] = useState([]); const [confirmAppleDisconnect, setConfirmAppleDisconnect] = useState(false); const [appleConnectError, setAppleConnectError] = useState<"denied" | "failed" | null>(null); + const [isGmailConnecting, setIsGmailConnecting] = useState(false); + const [confirmGmailDisconnect, setConfirmGmailDisconnect] = useState(false); + // Hidden until the main process confirms an OAuth client is configured, so + // dev/OSS builds without credentials don't show a tile that can only fail. + const [gmailConfigured, setGmailConfigured] = useState(false); // i18n prefix of the provider whose OAuth flow failed, e.g. "integrations.googleCalendar" const [oauthErrorKey, setOauthErrorKey] = useState(null); const [apiKeysDialogOpen, setApiKeysDialogOpen] = useState(false); @@ -280,6 +289,25 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView setAppleSourceNames([]); }, [setAppleCalendarConnected]); + const handleGmailConnect = useCallback(async () => { + setIsGmailConnecting(true); + try { + const result = await window.electronAPI?.gmailStartOAuth?.(); + if (result?.success && result.email) { + setGmailConnection(true, result.email); + } else if (!result?.error?.includes("access_denied")) { + setOauthErrorKey("integrations.gmail"); + } + } finally { + setIsGmailConnecting(false); + } + }, [setGmailConnection]); + + const handleGmailDisconnect = useCallback(async () => { + await window.electronAPI?.gmailDisconnect?.(); + setGmailConnection(false, null); + }, [setGmailConnection]); + const handleDisconnect = useCallback( async (email: string) => { setDisconnectingEmail(email); @@ -338,6 +366,18 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView return () => unsub?.(); }, [setMcalAccounts]); + useEffect(() => { + window.electronAPI?.gmailGetConnectionStatus?.().then((status) => { + if (!status) return; + setGmailConfigured(status.configured); + setGmailConnection(status.connected, status.email); + }); + const unsub = window.electronAPI?.onGmailConnectionChanged?.((data) => { + setGmailConnection(data.connected, data.email); + }); + return () => unsub?.(); + }, [setGmailConnection]); + useEffect(() => { if (!isMac) return; window.electronAPI?.acalGetConnectionStatus?.().then((status) => { @@ -429,6 +469,38 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView + {gmailConfigured && ( +
+ {t("integrations.sections.email")} + + + {gmailConnected && gmailEmail && ( + +
+ + + {gmailEmail} + + +
+
+ )} +
+
+ )} +
{t("integrations.sections.api")} @@ -542,6 +614,16 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView }} /> + + 0; const hasContent = content.length > 0; const noteCards = extractNoteCards(toolCalls, t("notes.list.untitledNote")); + const emailDrafts = extractEmailDrafts(toolCalls); return (
)} + {!isStreaming && + emailDrafts.map((draft) => )} + {hasContent && !isStreaming && (