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 && (