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} + + 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")} + > + + + + + )} + + + )} + {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 && ( void; + disabled: boolean; + emphasized?: boolean; +}) { + return ( + + + {label} + + {onChange ? ( + onChange(e.target.value)} + disabled={disabled} + spellCheck={false} + className={cn( + "flex-1 min-w-0 bg-transparent text-[12px] text-foreground rounded-sm", + emphasized && "font-medium", + "placeholder:text-muted-foreground/40", + "focus:outline-none focus-visible:ring-1 focus-visible:ring-ring/30", + "disabled:text-muted-foreground/60" + )} + /> + ) : ( + + {value} + + )} + + ); +} + +export function EmailDraftCard({ draft }: { draft: EmailDraftCardData }) { + const { t } = useTranslation(); + const [to, setTo] = useState(draft.to.join(", ")); + const [cc, setCc] = useState(draft.cc.join(", ")); + const [subject, setSubject] = useState(draft.subject); + const [body, setBody] = useState(draft.body); + const [sendState, setSendState] = useState(draft.sent ? "sent" : "draft"); + const [errorKey, setErrorKey] = useState(null); + const bodyRef = useRef(null); + + const isSent = sendState === "sent"; + const isSending = sendState === "sending"; + const showCc = draft.cc.length > 0 || cc.trim().length > 0; + + useEffect(() => { + const el = bodyRef.current; + if (!el) return; + el.style.height = "0px"; + el.style.height = `${Math.min(el.scrollHeight, 320)}px`; + }, [body, isSent]); + + const handleSend = async () => { + const toList = parseRecipients(to); + const ccList = parseRecipients(cc); + if (toList.length === 0 || [...toList, ...ccList].some((a) => !EMAIL_REGEX.test(a))) { + setErrorKey("agentMode.emailDraft.invalidRecipients"); + return; + } + + setErrorKey(null); + setSendState("sending"); + const result = await window.electronAPI?.gmailSendEmail?.({ + to: toList, + cc: ccList.length ? ccList : undefined, + subject, + body, + }); + if (result?.success) { + setSendState("sent"); + window.electronAPI?.updateAgentToolCall?.(draft.callId, { + to: toList, + cc: ccList, + subject, + body, + status: "sent", + }); + } else { + setSendState("failed"); + setErrorKey("agentMode.emailDraft.sendFailed"); + } + }; + + return ( + + + + + {t("agentMode.emailDraft.title")} + + {draft.from && ( + + {t("agentMode.emailDraft.via", { email: draft.from })} + + )} + + + + {showCc && ( + + )} + + + {isSent ? ( + + {body} + + ) : ( + setBody(e.target.value)} + disabled={isSending} + spellCheck={false} + className={cn( + "block w-full px-2.5 py-2 bg-transparent resize-none", + "text-[12px] leading-relaxed text-foreground", + "focus:outline-none focus-visible:ring-1 focus-visible:ring-ring/30 rounded-sm", + "disabled:text-muted-foreground/60" + )} + /> + )} + + + {isSent ? ( + + + + {t("agentMode.emailDraft.sent")} + + + ) : ( + <> + {errorKey && ( + + + {t(errorKey)} + + )} + + {isSending ? ( + + ) : ( + + )} + {isSending + ? t("agentMode.emailDraft.sending") + : sendState === "failed" + ? t("agentMode.emailDraft.retry") + : t("agentMode.emailDraft.send")} + + > + )} + + + ); +} diff --git a/src/components/chat/emailDrafts.ts b/src/components/chat/emailDrafts.ts new file mode 100644 index 0000000000..04b0f4f4de --- /dev/null +++ b/src/components/chat/emailDrafts.ts @@ -0,0 +1,41 @@ +import type { ToolCallInfo } from "./types"; + +export interface EmailDraftCardData { + callId: string; + to: string[]; + cc: string[]; + subject: string; + body: string; + from: string; + sent: boolean; +} + +export function parseRecipients(value: string): string[] { + return value + .split(/[,;]/) + .map((s) => s.trim()) + .filter(Boolean); +} + +// Email draft cards rendered under an assistant message, one per completed +// draft_email call whose metadata survived transport. +export function extractEmailDrafts(toolCalls?: ToolCallInfo[]): EmailDraftCardData[] { + if (!toolCalls) return []; + const drafts: EmailDraftCardData[] = []; + for (const tc of toolCalls) { + if (tc.name !== "draft_email" || tc.status !== "completed") continue; + const m = tc.metadata; + if (!m || Array.isArray(m)) continue; + if (typeof m.subject !== "string" || typeof m.body !== "string") continue; + drafts.push({ + callId: tc.id, + to: Array.isArray(m.to) ? m.to.map(String) : [], + cc: Array.isArray(m.cc) ? m.cc.map(String) : [], + subject: m.subject, + body: m.body, + from: typeof m.from === "string" ? m.from : "", + sent: m.status === "sent", + }); + } + return drafts; +} diff --git a/src/components/chat/toolIcons.ts b/src/components/chat/toolIcons.ts index 3155c8da13..4c6f27663d 100644 --- a/src/components/chat/toolIcons.ts +++ b/src/components/chat/toolIcons.ts @@ -1,4 +1,13 @@ -import { Search, Globe, ClipboardCheck, Calendar, FileText, FilePlus, FilePen } from "lucide-react"; +import { + Search, + Globe, + ClipboardCheck, + Calendar, + FileText, + FilePlus, + FilePen, + Mail, +} from "lucide-react"; export const toolIcons: Record = { search_notes: Search, @@ -8,4 +17,5 @@ export const toolIcons: Record = { get_note: FileText, create_note: FilePlus, update_note: FilePen, + draft_email: Mail, }; diff --git a/src/components/chat/useChatStreaming.ts b/src/components/chat/useChatStreaming.ts index 387301e003..4c815ed314 100644 --- a/src/components/chat/useChatStreaming.ts +++ b/src/components/chat/useChatStreaming.ts @@ -271,7 +271,8 @@ export function useChatStreaming({ const calendarConnected = settings.gcalConnected || settings.mcalConnected || settings.appleCalendarConnected; const webSearchEnabled = isWebSearchAllowed(usePolicyStore.getState()); - const cacheKey = `${settings.isSignedIn}-${calendarConnected}-${settings.cloudBackupEnabled}-${scopeKey}-${webSearchEnabled}`; + const gmailEmail = settings.gmailConnected ? settings.gmailEmail : ""; + const cacheKey = `${settings.isSignedIn}-${calendarConnected}-${settings.cloudBackupEnabled}-${scopeKey}-${webSearchEnabled}-${gmailEmail}`; if (toolRegistryRef.current?.key === cacheKey) { registry = toolRegistryRef.current.registry; } else { @@ -281,6 +282,7 @@ export function useChatStreaming({ cloudBackupEnabled: settings.cloudBackupEnabled, searchScope: scope, webSearchEnabled, + gmailEmail, }); toolRegistryRef.current = { key: cacheKey, registry }; } diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 875801836f..e73563c4a2 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -362,6 +362,7 @@ export default function NoteEditor({ noteTitle: note.title, noteContent: note.content, noteTranscript: note.transcript ?? undefined, + noteParticipants: note.participants, }); const titleRef = useRef(null); const prevNoteIdRef = useRef(note.id); diff --git a/src/config/prompts.ts b/src/config/prompts.ts index f823568e69..8fc63fc25e 100644 --- a/src/config/prompts.ts +++ b/src/config/prompts.ts @@ -41,6 +41,8 @@ const TOOL_INSTRUCTIONS: Record = { "Use copy_to_clipboard when the user asks you to copy something to their clipboard.", get_calendar_events: "Use get_calendar_events to check the user's schedule, upcoming meetings, or calendar events.", + draft_email: + "Use draft_email when the user asks you to draft, write, or send an email (such as a follow-up after a meeting). Compose it from the note content and transcript. Use participant emails from the context as recipients; never invent an email address — leave recipients empty when none are known. The draft appears as an editable card the user reviews and sends themselves.", }; export function getAgentSystemPrompt(availableTools?: string[], noteContext?: string): string { diff --git a/src/helpers/database.js b/src/helpers/database.js index d57da12e26..597639f4bb 100644 --- a/src/helpers/database.js +++ b/src/helpers/database.js @@ -3,6 +3,7 @@ const path = require("path"); const fs = require("fs"); const { randomUUID } = require("crypto"); const debugLogger = require("./debugLogger"); +const secretCrypto = require("./secretCrypto"); const { buildNoteSearchQuery } = require("./noteSearch"); const { normalizeStoredSpeakerCount } = require("./speakerCount"); const { app } = require("electron"); @@ -496,6 +497,19 @@ class DatabaseManager { if (!err.message.includes("duplicate column")) throw err; } + this.db.exec(` + CREATE TABLE IF NOT EXISTS gmail_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gmail_email TEXT NOT NULL UNIQUE, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + scope TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + this.db.exec(` CREATE TABLE IF NOT EXISTS microsoft_calendar_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -3155,6 +3169,37 @@ class DatabaseManager { } } + // Patches one tool call's metadata inside a persisted assistant message + // (e.g. marking an email draft as sent). The LIKE narrows candidates; the + // JSON parse confirms the match. + updateAgentToolCallMetadata(toolCallId, patch) { + try { + if (!this.db) throw new Error("Database not initialized"); + const rows = this.db + .prepare("SELECT id, metadata FROM agent_messages WHERE metadata LIKE ? ORDER BY id DESC") + .all(`%${toolCallId}%`); + for (const row of rows) { + let metadata; + try { + metadata = JSON.parse(row.metadata); + } catch { + continue; + } + const toolCall = metadata.toolCalls?.find((tc) => tc.id === toolCallId); + if (!toolCall?.metadata || Array.isArray(toolCall.metadata)) continue; + Object.assign(toolCall.metadata, patch); + this.db + .prepare("UPDATE agent_messages SET metadata = ? WHERE id = ?") + .run(JSON.stringify(metadata), row.id); + return { success: true }; + } + return { success: false }; + } catch (error) { + debugLogger.error("Error updating agent tool call", { error: error.message }, "database"); + return { success: false }; + } + } + getAllGoogleTokens() { try { if (!this.db) throw new Error("Database not initialized"); @@ -3215,6 +3260,98 @@ class DatabaseManager { } } + // A Gmail refresh token can send mail as the user, so unlike the calendar + // tables these columns are encrypted at rest via secretCrypto. The "enc:" + // prefix keeps reads working when no encryption backend exists (Linux + // without a keyring), where the value is stored plaintext. + _encryptGmailToken(value) { + if (!secretCrypto.isAvailable()) return value; + return `enc:${secretCrypto.encrypt(value).toString("base64")}`; + } + + _decryptGmailToken(value) { + if (!value?.startsWith("enc:")) return value; + return secretCrypto.decrypt(Buffer.from(value.slice(4), "base64")).value; + } + + _decryptGmailTokenRow(row) { + if (!row) return null; + return { + ...row, + access_token: this._decryptGmailToken(row.access_token), + refresh_token: this._decryptGmailToken(row.refresh_token), + }; + } + + saveGmailTokens(tokens) { + try { + if (!this.db) throw new Error("Database not initialized"); + // Single-account integration: dropping rows for any other email keeps a + // re-connect with a different account from leaving two senders behind. + this.db.transaction(() => { + this.db.prepare("DELETE FROM gmail_tokens WHERE gmail_email != ?").run(tokens.gmail_email); + this.db + .prepare( + `INSERT INTO gmail_tokens (gmail_email, access_token, refresh_token, expires_at, scope) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(gmail_email) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at, + scope = excluded.scope, + updated_at = CURRENT_TIMESTAMP` + ) + .run( + tokens.gmail_email, + this._encryptGmailToken(tokens.access_token), + this._encryptGmailToken(tokens.refresh_token), + tokens.expires_at, + tokens.scope + ); + })(); + return { success: true }; + } catch (error) { + debugLogger.error("Error saving Gmail tokens", { error: error.message }, "gmail"); + throw error; + } + } + + getGmailTokens() { + try { + if (!this.db) throw new Error("Database not initialized"); + return this._decryptGmailTokenRow( + this.db.prepare("SELECT * FROM gmail_tokens LIMIT 1").get() || null + ); + } catch (error) { + debugLogger.error("Error getting Gmail tokens", { error: error.message }, "gmail"); + throw error; + } + } + + getAllGmailTokens() { + try { + if (!this.db) throw new Error("Database not initialized"); + return this.db + .prepare("SELECT * FROM gmail_tokens") + .all() + .map((row) => this._decryptGmailTokenRow(row)); + } catch (error) { + debugLogger.error("Error getting all Gmail tokens", { error: error.message }, "gmail"); + throw error; + } + } + + deleteGmailTokens() { + try { + if (!this.db) throw new Error("Database not initialized"); + this.db.prepare("DELETE FROM gmail_tokens").run(); + return { success: true }; + } catch (error) { + debugLogger.error("Error deleting Gmail tokens", { error: error.message }, "gmail"); + throw error; + } + } + saveGoogleCalendars(calendars, accountEmail = null) { try { if (!this.db) throw new Error("Database not initialized"); diff --git a/src/helpers/gmailManager.js b/src/helpers/gmailManager.js new file mode 100644 index 0000000000..dc55466d60 --- /dev/null +++ b/src/helpers/gmailManager.js @@ -0,0 +1,115 @@ +const { net } = require("electron"); +const debugLogger = require("./debugLogger"); +const GmailOAuth = require("./gmailOAuth"); +const { broadcastToWindows } = require("./windowBroadcast"); + +const GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"; + +// RFC 2047 B-encoding for header values that aren't printable ASCII. +function encodeHeaderValue(value) { + if (/^[\x20-\x7e]*$/.test(value)) return value; + return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`; +} + +class GmailManager { + constructor(databaseManager) { + this.databaseManager = databaseManager; + this.oauth = new GmailOAuth(databaseManager); + } + + static buildRawMessage({ from, to, cc, subject, body }) { + const headers = [ + `From: ${from}`, + `To: ${to.join(", ")}`, + ...(cc?.length ? [`Cc: ${cc.join(", ")}`] : []), + `Subject: ${encodeHeaderValue(subject)}`, + "MIME-Version: 1.0", + 'Content-Type: text/plain; charset="UTF-8"', + "Content-Transfer-Encoding: base64", + ]; + const encodedBody = Buffer.from(body, "utf8") + .toString("base64") + .replace(/(.{76})/g, "$1\r\n"); + const message = `${headers.join("\r\n")}\r\n\r\n${encodedBody}`; + return Buffer.from(message, "utf8").toString("base64url"); + } + + async startOAuth() { + const result = await this.oauth.startOAuthFlow(); + this._broadcastConnectionChanged(); + return result; + } + + async revokeAllTokens() { + try { + const allTokens = this.databaseManager.getAllGmailTokens(); + await Promise.allSettled(allTokens.map((t) => this.oauth.revokeToken(t.access_token))); + } catch (err) { + debugLogger.error("Error revoking Gmail tokens", { error: err.message }, "gmail"); + } + this.disconnect(); + } + + disconnect() { + this.databaseManager.deleteGmailTokens(); + this._broadcastConnectionChanged(); + } + + getConnectionStatus() { + const tokens = this.databaseManager.getGmailTokens(); + return { + connected: Boolean(tokens), + email: tokens?.gmail_email || null, + configured: this.oauth.isConfigured(), + }; + } + + async sendEmail({ to, cc, subject, body }) { + const tokens = this.databaseManager.getGmailTokens(); + if (!tokens) throw new Error("Gmail is not connected"); + + const accessToken = await this.oauth.getValidAccessToken(); + const raw = GmailManager.buildRawMessage({ + from: tokens.gmail_email, + to, + cc, + subject, + body, + }); + + const response = await net.fetch(GMAIL_SEND_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ raw }), + signal: AbortSignal.timeout(10000), + useSessionCookies: false, + }); + const text = await response.text(); + let parsed = null; + try { + parsed = JSON.parse(text); + } catch { + // Error statuses can arrive with empty or non-JSON bodies; surface the + // status below instead of masking it as a parse failure. + } + if (response.status >= 400) { + const err = new Error(parsed?.error?.message || `Gmail API error ${response.status}`); + err.statusCode = response.status; + throw err; + } + if (parsed === null) { + throw new Error(`Invalid JSON response: ${text.slice(0, 200)}`); + } + + return { messageId: parsed.id }; + } + + _broadcastConnectionChanged() { + broadcastToWindows("gmail-connection-changed", this.getConnectionStatus()); + } +} + +module.exports = GmailManager; diff --git a/src/helpers/gmailOAuth.js b/src/helpers/gmailOAuth.js new file mode 100644 index 0000000000..cdb469affc --- /dev/null +++ b/src/helpers/gmailOAuth.js @@ -0,0 +1,164 @@ +const { net } = require("electron"); +const { runOAuthLoopbackFlow, OAuthFlowError } = require("./oauthLoopbackFlow"); + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +// gmail.send is Google's only sensitive-tier Gmail scope — adding any read or +// compose scope reclassifies the app as restricted (annual CASA audit). +const GMAIL_SCOPE = "openid email https://www.googleapis.com/auth/gmail.send"; + +class GmailOAuth { + constructor(databaseManager) { + this.databaseManager = databaseManager; + } + + getClientId() { + return process.env.GMAIL_CLIENT_ID || process.env.GOOGLE_CALENDAR_CLIENT_ID; + } + + getClientSecret() { + return process.env.GMAIL_CLIENT_SECRET || process.env.GOOGLE_CALENDAR_CLIENT_SECRET; + } + + isConfigured() { + return Boolean(this.getClientId() && this.getClientSecret()); + } + + startOAuthFlow() { + if (!this.isConfigured()) { + // Fail fast instead of opening the browser on a client_id=undefined URL + // and hanging until the loopback flow times out. + throw new Error("Gmail OAuth client is not configured"); + } + return runOAuthLoopbackFlow({ + errorParam: "gmail_error", + buildAuthUrl: (redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + client_id: this.getClientId(), + redirect_uri: redirectUri, + response_type: "code", + scope: GMAIL_SCOPE, + access_type: "offline", + prompt: "consent", + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + return `${GOOGLE_AUTH_URL}?${params.toString()}`; + }, + handleCallback: async (code, redirectUri, codeVerifier) => { + const tokenData = await this.exchangeCodeForTokens(code, redirectUri, codeVerifier); + + if (tokenData.error) { + throw new OAuthFlowError( + "token_exchange_failed", + `Token exchange failed: ${tokenData.error_description || tokenData.error}` + ); + } + + let email = null; + if (tokenData.id_token) { + try { + const payload = JSON.parse( + Buffer.from(tokenData.id_token.split(".")[1], "base64url").toString() + ); + email = payload.email; + } catch {} + } + + if (!email) { + throw new OAuthFlowError( + "no_email", + "Could not extract email from Google OAuth response" + ); + } + + this.databaseManager.saveGmailTokens({ + gmail_email: email, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: Date.now() + tokenData.expires_in * 1000, + scope: tokenData.scope || GMAIL_SCOPE, + }); + + return { success: true, email }; + }, + }); + } + + async exchangeCodeForTokens(code, redirectUri, codeVerifier) { + const body = new URLSearchParams({ + code, + client_id: this.getClientId(), + client_secret: this.getClientSecret(), + redirect_uri: redirectUri, + grant_type: "authorization_code", + code_verifier: codeVerifier, + }).toString(); + + return this._httpsPost(GOOGLE_TOKEN_URL, body); + } + + async refreshAccessToken(refreshToken) { + const body = new URLSearchParams({ + client_id: this.getClientId(), + client_secret: this.getClientSecret(), + refresh_token: refreshToken, + grant_type: "refresh_token", + }).toString(); + + return this._httpsPost(GOOGLE_TOKEN_URL, body); + } + + async getValidAccessToken() { + const tokens = this.databaseManager.getGmailTokens(); + if (!tokens) throw new Error("No Gmail tokens found"); + + const fiveMinutes = 5 * 60 * 1000; + if (tokens.expires_at - fiveMinutes < Date.now()) { + const refreshed = await this.refreshAccessToken(tokens.refresh_token); + if (refreshed.error) { + throw new Error(`Token refresh failed: ${refreshed.error_description || refreshed.error}`); + } + + this.databaseManager.saveGmailTokens({ + gmail_email: tokens.gmail_email, + access_token: refreshed.access_token, + refresh_token: tokens.refresh_token, + expires_at: Date.now() + refreshed.expires_in * 1000, + scope: tokens.scope, + }); + + return refreshed.access_token; + } + + return tokens.access_token; + } + + async revokeToken(token) { + const body = new URLSearchParams({ token }).toString(); + try { + await this._httpsPost("https://oauth2.googleapis.com/revoke", body); + } catch { + // Best-effort — token may already be revoked or network unavailable + } + } + + async _httpsPost(urlString, body) { + const response = await net.fetch(urlString, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(10000), + useSessionCookies: false, + }); + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + throw new Error(`Invalid JSON response: ${text.slice(0, 200)}`); + } + } +} + +module.exports = GmailOAuth; diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index db918fe43f..dcfd7b3c15 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -564,6 +564,7 @@ class IPCHandlers { this.googleCalendarManager = managers.googleCalendarManager; this.microsoftCalendarManager = managers.microsoftCalendarManager; this.appleCalendarManager = managers.appleCalendarManager; + this.gmailManager = managers.gmailManager; this.meetingDetectionEngine = managers.meetingDetectionEngine; this.audioTapManager = managers.audioTapManager; this.linuxPortalAudioManager = managers.linuxPortalAudioManager; @@ -2046,6 +2047,10 @@ class IPCHandlers { } ); + ipcMain.handle("db-update-agent-tool-call", async (event, toolCallId, patch) => { + return this.databaseManager.updateAgentToolCallMetadata(toolCallId, patch); + }); + ipcMain.handle("db-get-agent-messages", async (event, conversationId) => { return this.databaseManager.getAgentMessages(conversationId); }); @@ -3434,6 +3439,11 @@ class IPCHandlers { } catch (e) { errors.push(`GCal revoke: ${e.message}`); } + try { + await this.gmailManager?.revokeAllTokens(); + } catch (e) { + errors.push(`Gmail revoke: ${e.message}`); + } // Close DB connection before deleting the file try { @@ -10060,6 +10070,45 @@ class IPCHandlers { } }); + // Gmail (send-only) + ipcMain.handle("gmail-start-oauth", async () => { + try { + return await this.gmailManager.startOAuth(); + } catch (error) { + debugLogger.error("Gmail OAuth failed", { error: error.message }, "gmail"); + return { success: false, error: error.message }; + } + }); + + ipcMain.handle("gmail-disconnect", async () => { + try { + this.gmailManager.disconnect(); + return { success: true }; + } catch (error) { + debugLogger.error("Gmail disconnect failed", { error: error.message }, "gmail"); + return { success: false, error: error.message }; + } + }); + + ipcMain.handle("gmail-get-connection-status", async () => { + try { + return this.gmailManager.getConnectionStatus(); + } catch (error) { + debugLogger.error("Gmail connection status failed", { error: error.message }, "gmail"); + return { connected: false, email: null, configured: false }; + } + }); + + ipcMain.handle("gmail-send-email", async (_event, draft) => { + try { + const result = await this.gmailManager.sendEmail(draft); + return { success: true, ...result }; + } catch (error) { + debugLogger.error("Gmail send failed", { error: error.message }, "gmail"); + return { success: false, error: error.message }; + } + }); + ipcMain.handle("search-contacts", async (_event, query) => { try { const contacts = this.databaseManager.searchContacts(query); diff --git a/src/hooks/useEmbeddedChat.ts b/src/hooks/useEmbeddedChat.ts index b901b7b2fd..272dc3552f 100644 --- a/src/hooks/useEmbeddedChat.ts +++ b/src/hooks/useEmbeddedChat.ts @@ -10,6 +10,8 @@ interface UseEmbeddedChatOptions { noteTitle: string; noteContent: string; noteTranscript?: string; + /** Raw participants JSON from the note row (CalendarAttendee[]). */ + noteParticipants?: string | null; } interface NoteConversationItem { @@ -37,6 +39,7 @@ export function useEmbeddedChat({ noteTitle, noteContent, noteTranscript, + noteParticipants, }: UseEmbeddedChatOptions): UseEmbeddedChatReturn { const [conversationId, setConversationId] = useState(null); const [noteConversations, setNoteConversations] = useState([]); @@ -50,19 +53,34 @@ export function useEmbeddedChat({ }, }); - const noteContext = useMemo( - () => - [ - `Note ID: ${noteId}`, - folderId != null ? `Folder ID: ${folderId}` : "", - `Title: ${noteTitle}`, - `Content:\n${noteContent}`, - noteTranscript ? `\nTranscript:\n${noteTranscript}` : "", - ] - .filter(Boolean) - .join("\n"), - [folderId, noteContent, noteId, noteTitle, noteTranscript] - ); + const noteContext = useMemo(() => { + let participants = ""; + if (noteParticipants) { + try { + const parsed: Array<{ email?: string; displayName?: string | null; self?: boolean }> = + JSON.parse(noteParticipants); + const lines = parsed + .filter((p) => p.email) + .map( + (p) => + `- ${p.displayName ? `${p.displayName} <${p.email}>` : p.email}${p.self ? " (me)" : ""}` + ); + if (lines.length > 0) participants = `Participants:\n${lines.join("\n")}`; + } catch { + /* omit malformed participants */ + } + } + return [ + `Note ID: ${noteId}`, + folderId != null ? `Folder ID: ${folderId}` : "", + `Title: ${noteTitle}`, + `Content:\n${noteContent}`, + participants, + noteTranscript ? `\nTranscript:\n${noteTranscript}` : "", + ] + .filter(Boolean) + .join("\n"); + }, [folderId, noteContent, noteId, noteParticipants, noteTitle, noteTranscript]); const streaming = useChatStreaming({ messages: persistence.messages, diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 520ec7735b..56ec841c60 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -3456,7 +3456,21 @@ "copiedToClipboard": "In die Zwischenablage kopiert", "openNote": "Notiz öffnen", "unknownTool": "Unbekanntes Werkzeug: {{name}}", - "invalidArgs": "Ungültige Tool-Argumente für {{name}}" + "invalidArgs": "Ungültige Tool-Argumente für {{name}}", + "draft_emailStatus": "E-Mail wird entworfen..." + }, + "emailDraft": { + "title": "E-Mail-Entwurf", + "via": "über {{email}}", + "to": "An", + "cc": "Cc", + "subject": "Betreff", + "send": "Senden", + "sending": "Wird gesendet...", + "sent": "Gesendet", + "retry": "Erneut versuchen", + "invalidRecipients": "Gib eine gültige Empfängeradresse ein", + "sendFailed": "Senden fehlgeschlagen — prüfe deine Gmail-Verbindung" } }, "assistant": { @@ -3533,11 +3547,24 @@ "connectFailed": "Verbindung fehlgeschlagen", "connectFailedDescription": "Beim Verbinden mit Calendar.app ist ein Fehler aufgetreten. Starte OpenWhispr neu und versuche es erneut." }, + "gmail": { + "title": "Gmail", + "optional": "Optional", + "description": "Versende Follow-up-E-Mails aus deinen Notizen direkt im Chat.", + "connect": "Gmail verbinden", + "connected": "Verbunden", + "disconnect": "Trennen", + "disconnectConfirm": "{{email}} trennen?", + "disconnectDescription": "OpenWhispr kann von diesem Konto keine E-Mails mehr senden.", + "connectFailed": "Verbindung fehlgeschlagen", + "connectFailedDescription": "Beim Verbinden mit Gmail ist ein Fehler aufgetreten. Bitte versuche es erneut." + }, "sections": { "calendar": "Kalender", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "E-Mail" }, "api": { "title": "API-Schlüssel", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2332f28728..dbe1549587 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -3456,7 +3456,21 @@ "copiedToClipboard": "Copied to your clipboard", "openNote": "Open note", "unknownTool": "Unknown tool: {{name}}", - "invalidArgs": "Invalid tool arguments for {{name}}" + "invalidArgs": "Invalid tool arguments for {{name}}", + "draft_emailStatus": "Drafting email..." + }, + "emailDraft": { + "title": "Email draft", + "via": "via {{email}}", + "to": "To", + "cc": "Cc", + "subject": "Subject", + "send": "Send", + "sending": "Sending...", + "sent": "Sent", + "retry": "Retry", + "invalidRecipients": "Enter a valid recipient address", + "sendFailed": "Couldn't send — check your Gmail connection" } }, "assistant": { @@ -3533,11 +3547,24 @@ "connectFailed": "Connection Failed", "connectFailedDescription": "Something went wrong while connecting to Calendar.app. Please restart OpenWhispr and try again." }, + "gmail": { + "title": "Gmail", + "optional": "Optional", + "description": "Send follow-up emails drafted from your notes, right from chat.", + "connect": "Connect Gmail", + "connected": "Connected", + "disconnect": "Disconnect", + "disconnectConfirm": "Disconnect {{email}}?", + "disconnectDescription": "OpenWhispr will no longer be able to send emails from this account.", + "connectFailed": "Connection Failed", + "connectFailedDescription": "Something went wrong while connecting Gmail. Please try again." + }, "sections": { "calendar": "Calendar", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "Email" }, "api": { "title": "API keys", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 6ec8794c7d..6fcf545123 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -3339,7 +3339,21 @@ "copiedToClipboard": "Copiado al portapapeles", "openNote": "Abrir nota", "unknownTool": "Herramienta desconocida: {{name}}", - "invalidArgs": "Argumentos de herramienta no válidos para {{name}}" + "invalidArgs": "Argumentos de herramienta no válidos para {{name}}", + "draft_emailStatus": "Redactando correo..." + }, + "emailDraft": { + "title": "Borrador de correo", + "via": "vía {{email}}", + "to": "Para", + "cc": "Cc", + "subject": "Asunto", + "send": "Enviar", + "sending": "Enviando...", + "sent": "Enviado", + "retry": "Reintentar", + "invalidRecipients": "Introduce una dirección de destinatario válida", + "sendFailed": "No se pudo enviar: comprueba tu conexión con Gmail" } }, "assistant": { @@ -3416,11 +3430,24 @@ "connectFailed": "Error de conexión", "connectFailedDescription": "Se produjo un error al conectar con Calendar.app. Reinicia OpenWhispr e inténtalo de nuevo." }, + "gmail": { + "title": "Gmail", + "optional": "Opcional", + "description": "Envía correos de seguimiento redactados a partir de tus notas, directamente desde el chat.", + "connect": "Conectar Gmail", + "connected": "Conectado", + "disconnect": "Desconectar", + "disconnectConfirm": "¿Desconectar {{email}}?", + "disconnectDescription": "OpenWhispr ya no podrá enviar correos desde esta cuenta.", + "connectFailed": "Error de conexión", + "connectFailedDescription": "Se produjo un error al conectar con Gmail. Inténtalo de nuevo." + }, "sections": { "calendar": "Calendario", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "Correo" }, "api": { "title": "Claves API", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index ca5c82ab85..1d8301f468 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -3456,7 +3456,21 @@ "copiedToClipboard": "Copié dans le presse-papiers", "openNote": "Ouvrir la note", "unknownTool": "Outil inconnu : {{name}}", - "invalidArgs": "Arguments d'outil invalides pour {{name}}" + "invalidArgs": "Arguments d'outil invalides pour {{name}}", + "draft_emailStatus": "Rédaction de l'e-mail..." + }, + "emailDraft": { + "title": "Brouillon d'e-mail", + "via": "via {{email}}", + "to": "À", + "cc": "Cc", + "subject": "Objet", + "send": "Envoyer", + "sending": "Envoi...", + "sent": "Envoyé", + "retry": "Réessayer", + "invalidRecipients": "Saisissez une adresse de destinataire valide", + "sendFailed": "Échec de l'envoi — vérifiez votre connexion Gmail" } }, "assistant": { @@ -3533,11 +3547,24 @@ "connectFailed": "Échec de la connexion", "connectFailedDescription": "Une erreur s'est produite lors de la connexion à Calendar.app. Redémarrez OpenWhispr et réessayez." }, + "gmail": { + "title": "Gmail", + "optional": "Facultatif", + "description": "Envoyez des e-mails de suivi rédigés à partir de vos notes, directement depuis le chat.", + "connect": "Connecter Gmail", + "connected": "Connecté", + "disconnect": "Déconnecter", + "disconnectConfirm": "Déconnecter {{email}} ?", + "disconnectDescription": "OpenWhispr ne pourra plus envoyer d'e-mails depuis ce compte.", + "connectFailed": "Échec de la connexion", + "connectFailedDescription": "Une erreur s'est produite lors de la connexion à Gmail. Veuillez réessayer." + }, "sections": { "calendar": "Calendrier", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "E-mail" }, "api": { "title": "Clés API", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index f00514795a..fb1f773dcd 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -3291,7 +3291,21 @@ "copiedToClipboard": "Copiato negli appunti", "openNote": "Apri nota", "unknownTool": "Strumento sconosciuto: {{name}}", - "invalidArgs": "Argomenti dello strumento non validi per {{name}}" + "invalidArgs": "Argomenti dello strumento non validi per {{name}}", + "draft_emailStatus": "Stesura dell'email..." + }, + "emailDraft": { + "title": "Bozza email", + "via": "via {{email}}", + "to": "A", + "cc": "Cc", + "subject": "Oggetto", + "send": "Invia", + "sending": "Invio...", + "sent": "Inviata", + "retry": "Riprova", + "invalidRecipients": "Inserisci un indirizzo destinatario valido", + "sendFailed": "Invio non riuscito: controlla la connessione a Gmail" } }, "assistant": { @@ -3368,11 +3382,24 @@ "connectFailed": "Connessione non riuscita", "connectFailedDescription": "Si è verificato un errore durante la connessione a Calendar.app. Riavvia OpenWhispr e riprova." }, + "gmail": { + "title": "Gmail", + "optional": "Opzionale", + "description": "Invia email di follow-up create dalle tue note, direttamente dalla chat.", + "connect": "Collega Gmail", + "connected": "Connesso", + "disconnect": "Disconnetti", + "disconnectConfirm": "Disconnettere {{email}}?", + "disconnectDescription": "OpenWhispr non potrà più inviare email da questo account.", + "connectFailed": "Connessione non riuscita", + "connectFailedDescription": "Si è verificato un errore durante la connessione a Gmail. Riprova." + }, "sections": { "calendar": "Calendario", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "Email" }, "api": { "title": "Chiavi API", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index f20fe48abb..b5c16717f7 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -3291,7 +3291,21 @@ "copiedToClipboard": "クリップボードにコピーしました", "openNote": "ノートを開く", "unknownTool": "不明なツール: {{name}}", - "invalidArgs": "{{name}} のツール引数が無効です" + "invalidArgs": "{{name}} のツール引数が無効です", + "draft_emailStatus": "メールを作成中..." + }, + "emailDraft": { + "title": "メールの下書き", + "via": "{{email}} から送信", + "to": "宛先", + "cc": "Cc", + "subject": "件名", + "send": "送信", + "sending": "送信中...", + "sent": "送信済み", + "retry": "再試行", + "invalidRecipients": "有効な宛先アドレスを入力してください", + "sendFailed": "送信できませんでした。Gmail の接続を確認してください" } }, "assistant": { @@ -3368,11 +3382,24 @@ "connectFailed": "接続に失敗しました", "connectFailedDescription": "Calendar.app への接続中にエラーが発生しました。OpenWhispr を再起動してもう一度お試しください。" }, + "gmail": { + "title": "Gmail", + "optional": "任意", + "description": "ノートから作成したフォローアップメールをチャットから直接送信します。", + "connect": "Gmail に接続", + "connected": "接続済み", + "disconnect": "切断", + "disconnectConfirm": "{{email}} を切断しますか?", + "disconnectDescription": "OpenWhispr はこのアカウントからメールを送信できなくなります。", + "connectFailed": "接続に失敗しました", + "connectFailedDescription": "Gmail への接続中にエラーが発生しました。もう一度お試しください。" + }, "sections": { "calendar": "カレンダー", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "メール" }, "api": { "title": "APIキー", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index bffa0858e6..250942561e 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -3291,7 +3291,21 @@ "copiedToClipboard": "Copiado para a área de transferência", "openNote": "Abrir nota", "unknownTool": "Ferramenta desconhecida: {{name}}", - "invalidArgs": "Argumentos de ferramenta inválidos para {{name}}" + "invalidArgs": "Argumentos de ferramenta inválidos para {{name}}", + "draft_emailStatus": "Redigindo e-mail..." + }, + "emailDraft": { + "title": "Rascunho de e-mail", + "via": "via {{email}}", + "to": "Para", + "cc": "Cc", + "subject": "Assunto", + "send": "Enviar", + "sending": "Enviando...", + "sent": "Enviado", + "retry": "Tentar novamente", + "invalidRecipients": "Insira um endereço de destinatário válido", + "sendFailed": "Falha ao enviar — verifique sua conexão com o Gmail" } }, "assistant": { @@ -3368,11 +3382,24 @@ "connectFailed": "Falha na conexão", "connectFailedDescription": "Ocorreu um erro ao conectar ao Calendar.app. Reinicie o OpenWhispr e tente novamente." }, + "gmail": { + "title": "Gmail", + "optional": "Opcional", + "description": "Envie e-mails de acompanhamento criados a partir de suas notas, direto do chat.", + "connect": "Conectar Gmail", + "connected": "Conectado", + "disconnect": "Desconectar", + "disconnectConfirm": "Desconectar {{email}}?", + "disconnectDescription": "O OpenWhispr não poderá mais enviar e-mails desta conta.", + "connectFailed": "Falha na conexão", + "connectFailedDescription": "Ocorreu um erro ao conectar ao Gmail. Tente novamente." + }, "sections": { "calendar": "Calendário", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "E-mail" }, "api": { "title": "Chaves de API", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index e12e86fab8..636b0f543a 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -3317,7 +3317,21 @@ "copiedToClipboard": "Скопировано в буфер обмена", "openNote": "Открыть заметку", "unknownTool": "Неизвестный инструмент: {{name}}", - "invalidArgs": "Недопустимые аргументы инструмента для {{name}}" + "invalidArgs": "Недопустимые аргументы инструмента для {{name}}", + "draft_emailStatus": "Составление письма..." + }, + "emailDraft": { + "title": "Черновик письма", + "via": "через {{email}}", + "to": "Кому", + "cc": "Копия", + "subject": "Тема", + "send": "Отправить", + "sending": "Отправка...", + "sent": "Отправлено", + "retry": "Повторить", + "invalidRecipients": "Введите действительный адрес получателя", + "sendFailed": "Не удалось отправить — проверьте подключение Gmail" } }, "assistant": { @@ -3394,11 +3408,24 @@ "connectFailed": "Ошибка подключения", "connectFailedDescription": "При подключении к Calendar.app произошла ошибка. Перезапустите OpenWhispr и попробуйте снова." }, + "gmail": { + "title": "Gmail", + "optional": "Необязательно", + "description": "Отправляйте письма для follow-up, составленные из ваших заметок, прямо из чата.", + "connect": "Подключить Gmail", + "connected": "Подключено", + "disconnect": "Отключить", + "disconnectConfirm": "Отключить {{email}}?", + "disconnectDescription": "OpenWhispr больше не сможет отправлять письма с этого аккаунта.", + "connectFailed": "Ошибка подключения", + "connectFailedDescription": "При подключении к Gmail произошла ошибка. Попробуйте снова." + }, "sections": { "calendar": "Календарь", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "Почта" }, "api": { "title": "API-ключи", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 15d24d62ba..8c8e26370d 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -3291,7 +3291,21 @@ "copiedToClipboard": "已复制到剪贴板", "openNote": "打开笔记", "unknownTool": "未知工具:{{name}}", - "invalidArgs": "{{name}} 的工具参数无效" + "invalidArgs": "{{name}} 的工具参数无效", + "draft_emailStatus": "正在起草邮件..." + }, + "emailDraft": { + "title": "邮件草稿", + "via": "通过 {{email}} 发送", + "to": "收件人", + "cc": "抄送", + "subject": "主题", + "send": "发送", + "sending": "发送中...", + "sent": "已发送", + "retry": "重试", + "invalidRecipients": "请输入有效的收件人地址", + "sendFailed": "发送失败,请检查 Gmail 连接" } }, "assistant": { @@ -3368,11 +3382,24 @@ "connectFailed": "连接失败", "connectFailedDescription": "连接 Calendar.app 时出错。请重启 OpenWhispr 后重试。" }, + "gmail": { + "title": "Gmail", + "optional": "可选", + "description": "直接在聊天中发送根据笔记起草的跟进邮件。", + "connect": "连接 Gmail", + "connected": "已连接", + "disconnect": "断开", + "disconnectConfirm": "断开 {{email}}?", + "disconnectDescription": "OpenWhispr 将无法再从此账户发送邮件。", + "connectFailed": "连接失败", + "connectFailedDescription": "连接 Gmail 时出错。请重试。" + }, "sections": { "calendar": "日历", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "邮件" }, "api": { "title": "API 密钥", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 6788d5a2e5..663b1c7b43 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -3291,7 +3291,21 @@ "copiedToClipboard": "已複製到剪貼簿", "openNote": "開啟筆記", "unknownTool": "未知工具:{{name}}", - "invalidArgs": "{{name}} 的工具參數無效" + "invalidArgs": "{{name}} 的工具參數無效", + "draft_emailStatus": "正在草擬郵件..." + }, + "emailDraft": { + "title": "郵件草稿", + "via": "透過 {{email}} 傳送", + "to": "收件人", + "cc": "副本", + "subject": "主旨", + "send": "傳送", + "sending": "傳送中...", + "sent": "已傳送", + "retry": "重試", + "invalidRecipients": "請輸入有效的收件人地址", + "sendFailed": "傳送失敗,請檢查 Gmail 連線" } }, "assistant": { @@ -3368,11 +3382,24 @@ "connectFailed": "連線失敗", "connectFailedDescription": "連線 Calendar.app 時發生錯誤。請重新啟動 OpenWhispr 後再試一次。" }, + "gmail": { + "title": "Gmail", + "optional": "可選", + "description": "直接在聊天中傳送根據筆記草擬的跟進郵件。", + "connect": "連接 Gmail", + "connected": "已連接", + "disconnect": "中斷連接", + "disconnectConfirm": "中斷 {{email}} 的連接?", + "disconnectDescription": "OpenWhispr 將無法再從此帳戶傳送郵件。", + "connectFailed": "連線失敗", + "connectFailedDescription": "連線 Gmail 時發生錯誤。請再試一次。" + }, "sections": { "calendar": "行事曆", "api": "API", "mcp": "MCP", - "cli": "CLI" + "cli": "CLI", + "email": "郵件" }, "api": { "title": "API 金鑰", diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 66704b20ba..ab9b794261 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -903,11 +903,18 @@ class ReasoningService extends BaseReasoningService { const output = chunk.output; const displayText = typeof output === "string" ? output : output?.error ? String(output.error) : "Done"; + // Mirror the cloud path: successful object outputs become metadata so + // tool-result cards (note cards, email drafts) render on BYOK/local too. + const metadata = + output && typeof output === "object" && !("error" in output) + ? (output as Record | Array>) + : undefined; yield { type: "tool_result", callId: chunk.toolCallId, toolName: chunk.toolName, displayText, + ...(metadata ? { metadata } : {}), }; } else if (chunk.type === "abort") { canFlushFilteredText = false; diff --git a/src/services/tools/draftEmailTool.ts b/src/services/tools/draftEmailTool.ts new file mode 100644 index 0000000000..4b82b7b6ef --- /dev/null +++ b/src/services/tools/draftEmailTool.ts @@ -0,0 +1,58 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; +import { EMAIL_REGEX } from "../../utils/validation"; + +function invalidRecipient(addresses: string[]): string | undefined { + return addresses.find((address) => !EMAIL_REGEX.test(address)); +} + +// The tool only composes — sending happens when the user presses Send on the +// draft card, so the model can never dispatch an email on its own. +export function createDraftEmailTool(fromEmail: string): ToolDefinition { + return { + name: "draft_email", + description: + "Draft an email for the user to review, edit, and send from their connected Gmail account. The draft is shown as an editable card; it is never sent automatically.", + parameters: { + type: "object", + properties: { + to: { + type: "array", + items: { type: "string" }, + description: "Recipient email addresses. Leave empty if no address is known.", + }, + cc: { + type: "array", + items: { type: "string" }, + description: "CC email addresses", + }, + subject: { type: "string", description: "Email subject line" }, + body: { type: "string", description: "Plain-text email body" }, + }, + required: ["subject", "body"], + additionalProperties: false, + }, + readOnly: false, + + async execute(args: Record): Promise { + const to = Array.isArray(args.to) ? (args.to as string[]) : []; + const cc = Array.isArray(args.cc) ? (args.cc as string[]) : []; + const subject = String(args.subject ?? ""); + const body = String(args.body ?? ""); + + const invalid = invalidRecipient([...to, ...cc]); + if (invalid) { + return { + success: false, + data: null, + displayText: `Invalid email address: ${invalid}`, + }; + } + + return { + success: true, + data: { to, cc, subject, body, from: fromEmail, status: "draft" }, + displayText: `Drafted email: "${subject}"`, + }; + }, + }; +} diff --git a/src/services/tools/index.ts b/src/services/tools/index.ts index 4ce1debaf1..7253dc5d81 100644 --- a/src/services/tools/index.ts +++ b/src/services/tools/index.ts @@ -7,6 +7,7 @@ import { listFoldersTool } from "./listFoldersTool"; import { clipboardTool } from "./clipboardTool"; import { webSearchTool } from "./webSearchTool"; import { calendarTool } from "./calendarTool"; +import { createDraftEmailTool } from "./draftEmailTool"; import type { ContainerScope } from "../../types/chat"; export { ToolRegistry } from "./ToolRegistry"; @@ -19,6 +20,8 @@ interface ToolRegistrySettings { /** Pins search_notes to a container (overview chat); the LLM cannot widen it. */ searchScope?: ContainerScope; webSearchEnabled: boolean; + /** Connected Gmail address; empty/absent disables draft_email. */ + gmailEmail?: string; } export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry { @@ -40,5 +43,9 @@ export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry registry.register(calendarTool); } + if (settings.gmailEmail) { + registry.register(createDraftEmailTool(settings.gmailEmail)); + } + return registry; } diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index a43a8feeeb..281191d0c9 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -303,6 +303,7 @@ const BOOLEAN_SETTINGS = new Set([ "gcalPrimaryOnly", "mcalPrimaryOnly", "appleCalendarConnected", + "gmailConnected", ]); const ARRAY_SETTINGS = new Set([ @@ -629,6 +630,8 @@ export interface SettingsState gcalPrimaryOnly: boolean; mcalPrimaryOnly: boolean; appleCalendarConnected: boolean; + gmailConnected: boolean; + gmailEmail: string; meetingProcessDetection: boolean; speakerDiarizationEnabled: boolean; dictationSileroEnabled: boolean; @@ -929,6 +932,7 @@ export interface SettingsState setGcalPrimaryOnly: (value: boolean) => void; setMcalPrimaryOnly: (value: boolean) => void; setAppleCalendarConnected: (value: boolean) => void; + setGmailConnection: (connected: boolean, email: string | null) => void; setMeetingProcessDetection: (value: boolean) => void; setSpeakerDiarizationEnabled: (value: boolean) => void; setDictationSileroEnabled: (value: boolean) => void; @@ -1360,6 +1364,8 @@ export const useSettingsStore = create()((set, get) => ({ gcalPrimaryOnly: readBoolean("gcalPrimaryOnly", true), mcalPrimaryOnly: readBoolean("mcalPrimaryOnly", true), appleCalendarConnected: readBoolean("appleCalendarConnected", false), + gmailConnected: readBoolean("gmailConnected", false), + gmailEmail: readString("gmailEmail", ""), meetingProcessDetection: readBoolean("meetingProcessDetection", true), speakerDiarizationEnabled: readBoolean("speakerDiarizationEnabled", true), // Off by default: VAD on pause-heavy dictations can strip the speech and make @@ -2139,6 +2145,13 @@ export const useSettingsStore = create()((set, get) => ({ if (isBrowser) window.electronAPI?.mcalSetPrimaryOnly?.(value); }, setAppleCalendarConnected: createBooleanSetter("appleCalendarConnected"), + setGmailConnection: (connected: boolean, email: string | null) => { + if (isBrowser) { + localStorage.setItem("gmailConnected", String(connected)); + localStorage.setItem("gmailEmail", email ?? ""); + } + useSettingsStore.setState({ gmailConnected: connected, gmailEmail: email ?? "" }); + }, setMeetingProcessDetection: createBooleanSetter("meetingProcessDetection"), setSpeakerDiarizationEnabled: (value: boolean) => { if (isBrowser) localStorage.setItem("speakerDiarizationEnabled", String(value)); diff --git a/src/types/electron.ts b/src/types/electron.ts index d3ba7145d1..fd39c613ed 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -109,6 +109,12 @@ export type ProxyTranscriptionResult = | { text: string; model?: string; error?: undefined } | { error: string; code?: string; messageKey?: string; text?: undefined }; +export interface GmailConnectionStatus { + connected: boolean; + email: string | null; + configured: boolean; +} + export interface AuthTokenState { token: string | null; generation: number; @@ -2267,6 +2273,10 @@ declare global { metadata?: string; created_at: string; } | null>; + updateAgentToolCall?: ( + toolCallId: string, + patch: Record + ) => Promise<{ success: boolean }>; getAgentMessages?: (conversationId: number) => Promise< Array<{ id: number; @@ -2649,6 +2659,18 @@ declare global { ) => () => void; onAcalEventsSynced?: (callback: (data: any) => void) => () => void; + // Gmail (send-only) + gmailStartOAuth?: () => Promise<{ success: boolean; email?: string; error?: string }>; + gmailDisconnect?: () => Promise<{ success: boolean; error?: string }>; + gmailGetConnectionStatus?: () => Promise; + gmailSendEmail?: (draft: { + to: string[]; + cc?: string[]; + subject: string; + body: string; + }) => Promise<{ success: boolean; messageId?: string; error?: string }>; + onGmailConnectionChanged?: (callback: (data: GmailConnectionStatus) => void) => () => void; + meetingDetectionGetPreferences?: () => Promise<{ success: boolean; preferences?: any }>; meetingDetectionSetPreferences?: ( prefs: Record diff --git a/test/helpers/gmailDatabase.test.js b/test/helpers/gmailDatabase.test.js new file mode 100644 index 0000000000..8871189ba8 --- /dev/null +++ b/test/helpers/gmailDatabase.test.js @@ -0,0 +1,178 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const Module = require("node:module"); + +let userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "openwhispr-gmail-db-")); +const originalLoad = Module._load; + +// Deterministic secretCrypto stand-in: real backends hit the OS keychain. +let cryptoAvailable = true; +const fakeSecretCrypto = { + isAvailable: () => cryptoAvailable, + encrypt: (plaintext) => Buffer.from(`sealed:${plaintext}`, "utf8"), + decrypt: (blob) => ({ + value: blob.toString("utf8").replace(/^sealed:/, ""), + needsReencrypt: false, + }), +}; + +Module._load = function patchedLoad(request, parent, isMain) { + if (request === "electron") { + return { + app: { + getPath: () => userDataDir, + getAppPath: () => process.cwd(), + isReady: () => false, + }, + }; + } + if (request === "./secretCrypto") { + return fakeSecretCrypto; + } + return originalLoad.call(this, request, parent, isMain); +}; + +process.env.NODE_ENV = "test"; + +const DatabaseManager = require("../../src/helpers/database.js"); + +function isNativeBindingUnavailable(error) { + const message = String(error?.message || error); + return ( + message.includes("NODE_MODULE_VERSION") || + message.includes("Could not locate the bindings file") + ); +} + +function createDb(t) { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "openwhispr-gmail-db-")); + try { + return new DatabaseManager(); + } catch (error) { + if (isNativeBindingUnavailable(error)) { + t.skip("better-sqlite3 native binding is not available for this Node runtime"); + return null; + } + throw error; + } +} + +const tokens = (overrides = {}) => ({ + gmail_email: "me@example.com", + access_token: "access-1", + refresh_token: "refresh-1", + expires_at: 1234567890, + scope: "gmail.send", + ...overrides, +}); + +test("gmail tokens are encrypted at rest and decrypted on read", (t) => { + cryptoAvailable = true; + const db = createDb(t); + if (!db) return; + + db.saveGmailTokens(tokens()); + + const rawRow = db.db.prepare("SELECT * FROM gmail_tokens").get(); + assert.ok(rawRow.access_token.startsWith("enc:"), "access token must not be stored plaintext"); + assert.ok(rawRow.refresh_token.startsWith("enc:"), "refresh token must not be stored plaintext"); + assert.ok(!rawRow.access_token.includes("access-1")); + + const row = db.getGmailTokens(); + assert.equal(row.access_token, "access-1"); + assert.equal(row.refresh_token, "refresh-1"); + assert.equal(row.gmail_email, "me@example.com"); + + assert.equal(db.getAllGmailTokens()[0].refresh_token, "refresh-1"); + db.db.close(); +}); + +test("gmail tokens fall back to plaintext when no encryption backend exists", (t) => { + cryptoAvailable = false; + const db = createDb(t); + if (!db) return; + + db.saveGmailTokens(tokens()); + const rawRow = db.db.prepare("SELECT * FROM gmail_tokens").get(); + assert.equal(rawRow.access_token, "access-1"); + assert.equal(db.getGmailTokens().access_token, "access-1"); + + cryptoAvailable = true; + db.db.close(); +}); + +test("connecting a different account replaces the previous one", (t) => { + cryptoAvailable = true; + const db = createDb(t); + if (!db) return; + + db.saveGmailTokens(tokens()); + db.saveGmailTokens(tokens({ gmail_email: "other@example.com", access_token: "access-2" })); + + const rows = db.getAllGmailTokens(); + assert.equal(rows.length, 1, "gmail integration is single-account"); + assert.equal(rows[0].gmail_email, "other@example.com"); + assert.equal(rows[0].access_token, "access-2"); + db.db.close(); +}); + +test("saving the same account refreshes tokens in place", (t) => { + cryptoAvailable = true; + const db = createDb(t); + if (!db) return; + + db.saveGmailTokens(tokens()); + db.saveGmailTokens(tokens({ access_token: "access-refreshed" })); + + const rows = db.getAllGmailTokens(); + assert.equal(rows.length, 1); + assert.equal(rows[0].access_token, "access-refreshed"); + db.db.close(); +}); + +test("deleteGmailTokens clears the table", (t) => { + cryptoAvailable = true; + const db = createDb(t); + if (!db) return; + + db.saveGmailTokens(tokens()); + db.deleteGmailTokens(); + assert.equal(db.getGmailTokens(), null); + db.db.close(); +}); + +test("updateAgentToolCallMetadata patches a persisted draft in place", (t) => { + cryptoAvailable = true; + const db = createDb(t); + if (!db) return; + + const conv = db.createAgentConversation("Test chat"); + const metadata = { + toolCalls: [ + { + id: "call-1", + name: "draft_email", + status: "completed", + metadata: { subject: "Hi", body: "Draft", status: "draft" }, + }, + ], + }; + db.addAgentMessage(conv.id, "assistant", "Drafted an email", metadata); + + const result = db.updateAgentToolCallMetadata("call-1", { status: "sent", subject: "Hello" }); + assert.equal(result.success, true); + + const stored = JSON.parse( + db.db.prepare("SELECT metadata FROM agent_messages WHERE conversation_id = ?").get(conv.id) + .metadata + ); + assert.equal(stored.toolCalls[0].metadata.status, "sent"); + assert.equal(stored.toolCalls[0].metadata.subject, "Hello"); + assert.equal(stored.toolCalls[0].metadata.body, "Draft", "unpatched fields survive"); + + assert.equal(db.updateAgentToolCallMetadata("missing-call", { status: "sent" }).success, false); + db.db.close(); +}); diff --git a/test/helpers/gmailManager.test.js b/test/helpers/gmailManager.test.js new file mode 100644 index 0000000000..da2fa9449f --- /dev/null +++ b/test/helpers/gmailManager.test.js @@ -0,0 +1,194 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const Module = require("node:module"); + +const managerModulePath = require.resolve("../../src/helpers/gmailManager.js"); +const originalLoad = Module._load; + +// Swapped per-test so mocked net.fetch calls route to the current stub. +let fetchImpl = async () => { + throw new Error("fetch not stubbed"); +}; + +function loadManagerModule() { + delete require.cache[managerModulePath]; + delete require.cache[require.resolve("../../src/helpers/gmailOAuth.js")]; + Module._load = function loadWithElectronMock(request, parent, isMain) { + if (request === "electron") { + return { + net: { fetch: (...args) => fetchImpl(...args) }, + BrowserWindow: { getAllWindows: () => [] }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + try { + return require(managerModulePath); + } finally { + Module._load = originalLoad; + } +} + +function decodeRaw(raw) { + return Buffer.from(raw, "base64url").toString("utf8"); +} + +function splitMessage(raw) { + const message = decodeRaw(raw); + const [headerBlock, bodyBlock] = message.split("\r\n\r\n"); + return { headers: headerBlock.split("\r\n"), body: bodyBlock }; +} + +test("buildRawMessage produces base64url with full RFC 2822 framing", () => { + const GmailManager = loadManagerModule(); + const raw = GmailManager.buildRawMessage({ + from: "me@example.com", + to: ["a@example.com", "b@example.com"], + cc: ["c@example.com"], + subject: "Meeting follow-up", + body: "Hi team,\nThanks for today.", + }); + + assert.match(raw, /^[A-Za-z0-9_-]+$/, "raw must use the base64url alphabet only"); + + const { headers, body } = splitMessage(raw); + assert.ok(headers.includes("From: me@example.com")); + assert.ok(headers.includes("To: a@example.com, b@example.com")); + assert.ok(headers.includes("Cc: c@example.com")); + assert.ok(headers.includes("Subject: Meeting follow-up")); + assert.ok(headers.includes('Content-Type: text/plain; charset="UTF-8"')); + assert.ok(headers.includes("Content-Transfer-Encoding: base64")); + + const decodedBody = Buffer.from(body.replace(/\r\n/g, ""), "base64").toString("utf8"); + assert.equal(decodedBody, "Hi team,\nThanks for today."); +}); + +test("buildRawMessage RFC 2047-encodes non-ASCII subjects and preserves UTF-8 bodies", () => { + const GmailManager = loadManagerModule(); + const raw = GmailManager.buildRawMessage({ + from: "me@example.com", + to: ["a@example.com"], + subject: "Résumé — 会議", + body: "Danke schön — ありがとう", + }); + + const { headers, body } = splitMessage(raw); + const subjectHeader = headers.find((h) => h.startsWith("Subject: ")); + const match = subjectHeader.match(/^Subject: =\?UTF-8\?B\?(.+)\?=$/); + assert.ok(match, `subject must be B-encoded, got: ${subjectHeader}`); + assert.equal(Buffer.from(match[1], "base64").toString("utf8"), "Résumé — 会議"); + + const decodedBody = Buffer.from(body.replace(/\r\n/g, ""), "base64").toString("utf8"); + assert.equal(decodedBody, "Danke schön — ありがとう"); +}); + +test("buildRawMessage omits the Cc header when there are no cc recipients", () => { + const GmailManager = loadManagerModule(); + const raw = GmailManager.buildRawMessage({ + from: "me@example.com", + to: ["a@example.com"], + subject: "No cc", + body: "body", + }); + const { headers } = splitMessage(raw); + assert.ok(!headers.some((h) => h.startsWith("Cc:"))); +}); + +test("sendEmail rejects when Gmail is not connected", async () => { + const GmailManager = loadManagerModule(); + const manager = new GmailManager({ getGmailTokens: () => null }); + await assert.rejects(() => manager.sendEmail({ to: ["a@b.co"], subject: "s", body: "b" }), { + message: "Gmail is not connected", + }); +}); + +test("sendEmail posts the raw message with a bearer token and returns the message id", async () => { + const GmailManager = loadManagerModule(); + const manager = new GmailManager({ + getGmailTokens: () => ({ gmail_email: "me@example.com" }), + }); + manager.oauth.getValidAccessToken = async () => "access-token-1"; + + const calls = []; + fetchImpl = async (url, options) => { + calls.push({ url, options }); + return { status: 200, text: async () => JSON.stringify({ id: "msg-1" }) }; + }; + + const result = await manager.sendEmail({ + to: ["a@example.com"], + subject: "Hello", + body: "World", + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"); + assert.equal(calls[0].options.method, "POST"); + assert.equal(calls[0].options.headers.Authorization, "Bearer access-token-1"); + const payload = JSON.parse(calls[0].options.body); + const { headers } = splitMessage(payload.raw); + assert.ok(headers.includes("From: me@example.com")); + assert.deepEqual(result, { messageId: "msg-1" }); +}); + +test("sendEmail surfaces Gmail API errors with the status code", async () => { + const GmailManager = loadManagerModule(); + const manager = new GmailManager({ + getGmailTokens: () => ({ gmail_email: "me@example.com" }), + }); + manager.oauth.getValidAccessToken = async () => "access-token-1"; + + fetchImpl = async () => ({ + status: 403, + text: async () => JSON.stringify({ error: { message: "Quota exceeded" } }), + }); + + await assert.rejects( + () => manager.sendEmail({ to: ["a@example.com"], subject: "s", body: "b" }), + (err) => err.message === "Quota exceeded" && err.statusCode === 403 + ); +}); + +test("getConnectionStatus reflects tokens and client configuration", () => { + const GmailManager = loadManagerModule(); + const prevId = process.env.GMAIL_CLIENT_ID; + const prevSecret = process.env.GMAIL_CLIENT_SECRET; + process.env.GMAIL_CLIENT_ID = "id"; + process.env.GMAIL_CLIENT_SECRET = "secret"; + try { + const connected = new GmailManager({ + getGmailTokens: () => ({ gmail_email: "me@example.com" }), + }); + assert.deepEqual(connected.getConnectionStatus(), { + connected: true, + email: "me@example.com", + configured: true, + }); + + const disconnected = new GmailManager({ getGmailTokens: () => null }); + assert.deepEqual(disconnected.getConnectionStatus(), { + connected: false, + email: null, + configured: true, + }); + } finally { + if (prevId === undefined) delete process.env.GMAIL_CLIENT_ID; + else process.env.GMAIL_CLIENT_ID = prevId; + if (prevSecret === undefined) delete process.env.GMAIL_CLIENT_SECRET; + else process.env.GMAIL_CLIENT_SECRET = prevSecret; + } +}); + +test("disconnect deletes stored tokens", () => { + const GmailManager = loadManagerModule(); + let deleted = false; + const manager = new GmailManager({ + getGmailTokens: () => null, + deleteGmailTokens: () => { + deleted = true; + return { success: true }; + }, + }); + manager.disconnect(); + assert.equal(deleted, true); +}); diff --git a/test/services/draftEmailTool.test.js b/test/services/draftEmailTool.test.js new file mode 100644 index 0000000000..0669bcc7cf --- /dev/null +++ b/test/services/draftEmailTool.test.js @@ -0,0 +1,49 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const load = () => import("../../src/services/tools/draftEmailTool.ts"); + +test("draft_email returns the draft as metadata-ready data without sending", async () => { + const { createDraftEmailTool } = await load(); + const tool = createDraftEmailTool("me@example.com"); + + const result = await tool.execute({ + to: ["a@example.com"], + cc: ["b@example.com"], + subject: "Follow-up", + body: "Thanks for the meeting.", + }); + + assert.equal(result.success, true); + assert.deepEqual(result.data, { + to: ["a@example.com"], + cc: ["b@example.com"], + subject: "Follow-up", + body: "Thanks for the meeting.", + from: "me@example.com", + status: "draft", + }); + assert.equal(result.displayText, 'Drafted email: "Follow-up"'); +}); + +test("draft_email allows empty recipients so the user can fill them in", async () => { + const { createDraftEmailTool } = await load(); + const tool = createDraftEmailTool("me@example.com"); + + const result = await tool.execute({ subject: "s", body: "b" }); + assert.equal(result.success, true); + assert.deepEqual(result.data.to, []); +}); + +test("draft_email rejects invalid recipient addresses", async () => { + const { createDraftEmailTool } = await load(); + const tool = createDraftEmailTool("me@example.com"); + + const result = await tool.execute({ + to: ["not-an-email"], + subject: "s", + body: "b", + }); + assert.equal(result.success, false); + assert.match(result.displayText, /not-an-email/); +});
+ {body} +