Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
4 changes: 4 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -464,6 +466,7 @@ function initializeCoreManagers() {
calendarReminderScheduler
);
appleCalendarManager = new AppleCalendarManager(databaseManager, calendarReminderScheduler);
gmailManager = new GmailManager(databaseManager);
meetingDetectionEngine = new MeetingDetectionEngine(
calendarReminderScheduler,
new MeetingProcessDetector(),
Expand Down Expand Up @@ -522,6 +525,7 @@ function initializeCoreManagers() {
googleCalendarManager,
microsoftCalendarManager,
appleCalendarManager,
gmailManager,
meetingDetectionEngine,
audioTapManager,
linuxPortalAudioManager,
Expand Down
14 changes: 14 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) =>
Expand Down
7 changes: 7 additions & 0 deletions src/assets/icons/gmail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions src/components/IntegrationsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string | null>(null);
Expand All @@ -183,6 +187,11 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView
const [appleSourceNames, setAppleSourceNames] = useState<string[]>([]);
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<string | null>(null);
const [apiKeysDialogOpen, setApiKeysDialogOpen] = useState(false);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -429,6 +469,38 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView
</SettingsPanel>
</div>

{gmailConfigured && (
<div>
<SectionLabel>{t("integrations.sections.email")}</SectionLabel>
<SettingsPanel>
<ProviderRow
icon={gmailIcon}
i18nKey="integrations.gmail"
connected={gmailConnected}
isConnecting={isGmailConnecting}
onConnect={handleGmailConnect}
/>
{gmailConnected && gmailEmail && (
<SettingsPanelRow>
<div className="group flex items-center gap-3 pl-12">
<Mail className="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
<span className="text-xs text-muted-foreground truncate flex-1">
{gmailEmail}
</span>
<button
onClick={() => setConfirmGmailDisconnect(true)}
className="opacity-0 group-hover:opacity-100 p-1 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
aria-label={t("integrations.gmail.disconnect")}
>
<Unlink className="h-3.5 w-3.5" />
</button>
</div>
</SettingsPanelRow>
)}
</SettingsPanel>
</div>
)}

<div>
<SectionLabel>{t("integrations.sections.api")}</SectionLabel>
<SettingsPanel>
Expand Down Expand Up @@ -542,6 +614,16 @@ export default function IntegrationsView({ isPaid, onUpgrade }: IntegrationsView
}}
/>

<ConfirmDialog
open={confirmGmailDisconnect}
onOpenChange={setConfirmGmailDisconnect}
title={t("integrations.gmail.disconnectConfirm", { email: gmailEmail })}
description={t("integrations.gmail.disconnectDescription")}
confirmText={t("integrations.gmail.disconnect")}
variant="destructive"
onConfirm={handleGmailDisconnect}
/>

<ConfirmDialog
open={showPermissionDialog}
onOpenChange={setShowPermissionDialog}
Expand Down
6 changes: 6 additions & 0 deletions src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { cn } from "../lib/utils";
import { MarkdownRenderer } from "../ui/MarkdownRenderer";
import type { ToolCallInfo } from "./types";
import { extractNoteCards } from "./noteCards";
import { EmailDraftCard } from "./EmailDraftCard";
import { extractEmailDrafts } from "./emailDrafts";
import { toolIcons } from "./toolIcons";

interface ChatMessageProps {
Expand Down Expand Up @@ -208,6 +210,7 @@ export function ChatMessage({
const hasToolCalls = toolCalls && toolCalls.length > 0;
const hasContent = content.length > 0;
const noteCards = extractNoteCards(toolCalls, t("notes.list.untitledNote"));
const emailDrafts = extractEmailDrafts(toolCalls);

return (
<div
Expand Down Expand Up @@ -267,6 +270,9 @@ export function ChatMessage({
</div>
)}

{!isStreaming &&
emailDrafts.map((draft) => <EmailDraftCard key={draft.callId} draft={draft} />)}

{hasContent && !isStreaming && (
<div className="flex justify-start mt-1.5 -mb-0.5">
<button
Expand Down
Loading
Loading