mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-09 23:32:37 +02:00
feat: move gmail sync to composio OAuth and remove calendar sync
Migrate gmail_sync to use Composio for OAuth authentication instead of direct Google OAuth. Add gmail connection UI to onboarding and connectors. Remove calendar sync functionality. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa2a830f23
commit
d12150f1bf
6 changed files with 542 additions and 281 deletions
|
|
@ -3,6 +3,7 @@ import { createAuthServer } from './auth-server.js';
|
||||||
import * as composioClient from '@x/core/dist/composio/client.js';
|
import * as composioClient from '@x/core/dist/composio/client.js';
|
||||||
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
||||||
import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
|
import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
|
||||||
|
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||||
|
|
||||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
||||||
|
|
||||||
|
|
@ -151,6 +152,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||||
|
|
||||||
if (accountStatus.status === 'ACTIVE') {
|
if (accountStatus.status === 'ACTIVE') {
|
||||||
emitComposioEvent({ toolkitSlug, success: true });
|
emitComposioEvent({ toolkitSlug, success: true });
|
||||||
|
if (toolkitSlug === 'gmail') {
|
||||||
|
triggerGmailSync();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
emitComposioEvent({
|
emitComposioEvent({
|
||||||
toolkitSlug,
|
toolkitSlug,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||||
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
|
||||||
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
|
|
||||||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||||
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
||||||
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
||||||
|
|
@ -134,9 +134,6 @@ app.whenReady().then(async () => {
|
||||||
// start gmail sync
|
// start gmail sync
|
||||||
initGmailSync();
|
initGmailSync();
|
||||||
|
|
||||||
// start calendar sync
|
|
||||||
initCalendarSync();
|
|
||||||
|
|
||||||
// start fireflies sync
|
// start fireflies sync
|
||||||
initFirefliesSync();
|
initFirefliesSync();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/prov
|
||||||
import container from '@x/core/dist/di/container.js';
|
import container from '@x/core/dist/di/container.js';
|
||||||
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||||
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
|
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
|
||||||
import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js';
|
|
||||||
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js';
|
||||||
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
|
||||||
import { emitOAuthEvent, emitAuthEvent } from './ipc.js';
|
import { emitOAuthEvent, emitAuthEvent } from './ipc.js';
|
||||||
|
|
@ -260,7 +259,6 @@ export async function connectProvider(provider: string): Promise<{ success: bool
|
||||||
|
|
||||||
// Trigger immediate sync for relevant providers
|
// Trigger immediate sync for relevant providers
|
||||||
if (provider === 'google') {
|
if (provider === 'google') {
|
||||||
triggerGmailSync();
|
|
||||||
triggerCalendarSync();
|
triggerCalendarSync();
|
||||||
} else if (provider === 'fireflies-ai') {
|
} else if (provider === 'fireflies-ai') {
|
||||||
triggerFirefliesSync();
|
triggerFirefliesSync();
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||||
|
|
||||||
// Composio/Slack state
|
// Composio state (Gmail + Slack)
|
||||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||||
|
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'gmail' | 'slack'>('gmail')
|
||||||
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
const [slackConnected, setSlackConnected] = useState(false)
|
const [slackConnected, setSlackConnected] = useState(false)
|
||||||
const [slackLoading, setSlackLoading] = useState(true)
|
const [slackLoading, setSlackLoading] = useState(true)
|
||||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||||
|
|
@ -93,6 +97,20 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load Gmail connection status
|
||||||
|
const refreshGmailStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailLoading(true)
|
||||||
|
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||||
|
setGmailConnected(result.isConnected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Gmail status:', error)
|
||||||
|
setGmailConnected(false)
|
||||||
|
} finally {
|
||||||
|
setGmailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Load Slack connection status
|
// Load Slack connection status
|
||||||
const refreshSlackStatus = useCallback(async () => {
|
const refreshSlackStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,6 +125,53 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Connect to Gmail via Composio
|
||||||
|
const startGmailConnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailConnecting(true)
|
||||||
|
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || 'Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
// Success will be handled by composio:didConnect event
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Gmail:', error)
|
||||||
|
toast.error('Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle Gmail connect button click
|
||||||
|
const handleConnectGmail = useCallback(async () => {
|
||||||
|
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||||
|
if (!configResult.configured) {
|
||||||
|
setComposioApiKeyTarget('gmail')
|
||||||
|
setComposioApiKeyOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await startGmailConnect()
|
||||||
|
}, [startGmailConnect])
|
||||||
|
|
||||||
|
// Disconnect from Gmail
|
||||||
|
const handleDisconnectGmail = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailLoading(true)
|
||||||
|
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' })
|
||||||
|
if (result.success) {
|
||||||
|
setGmailConnected(false)
|
||||||
|
toast.success('Disconnected from Gmail')
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to disconnect from Gmail')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect from Gmail:', error)
|
||||||
|
toast.error('Failed to disconnect from Gmail')
|
||||||
|
} finally {
|
||||||
|
setGmailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Connect to Slack via Composio
|
// Connect to Slack via Composio
|
||||||
const startSlackConnect = useCallback(async () => {
|
const startSlackConnect = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -126,9 +191,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
|
|
||||||
// Handle Slack connect button click
|
// Handle Slack connect button click
|
||||||
const handleConnectSlack = useCallback(async () => {
|
const handleConnectSlack = useCallback(async () => {
|
||||||
// Check if Composio is configured
|
|
||||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||||
if (!configResult.configured) {
|
if (!configResult.configured) {
|
||||||
|
setComposioApiKeyTarget('slack')
|
||||||
setComposioApiKeyOpen(true)
|
setComposioApiKeyOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -141,13 +206,17 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||||
setComposioApiKeyOpen(false)
|
setComposioApiKeyOpen(false)
|
||||||
toast.success('Composio API key saved')
|
toast.success('Composio API key saved')
|
||||||
// Now start the Slack connection
|
// Start the connection for whichever toolkit triggered the API key prompt
|
||||||
await startSlackConnect()
|
if (composioApiKeyTarget === 'gmail') {
|
||||||
|
await startGmailConnect()
|
||||||
|
} else {
|
||||||
|
await startSlackConnect()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save Composio API key:', error)
|
console.error('Failed to save Composio API key:', error)
|
||||||
toast.error('Failed to save API key')
|
toast.error('Failed to save API key')
|
||||||
}
|
}
|
||||||
}, [startSlackConnect])
|
}, [composioApiKeyTarget, startGmailConnect, startSlackConnect])
|
||||||
|
|
||||||
// Disconnect from Slack
|
// Disconnect from Slack
|
||||||
const handleDisconnectSlack = useCallback(async () => {
|
const handleDisconnectSlack = useCallback(async () => {
|
||||||
|
|
@ -173,7 +242,8 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
// Refresh Granola
|
// Refresh Granola
|
||||||
refreshGranolaConfig()
|
refreshGranolaConfig()
|
||||||
|
|
||||||
// Refresh Slack status
|
// Refresh Composio connections
|
||||||
|
refreshGmailStatus()
|
||||||
refreshSlackStatus()
|
refreshSlackStatus()
|
||||||
|
|
||||||
// Refresh OAuth providers
|
// Refresh OAuth providers
|
||||||
|
|
@ -202,7 +272,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
)
|
)
|
||||||
|
|
||||||
setProviderStates(newStates)
|
setProviderStates(newStates)
|
||||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
}, [providers, refreshGranolaConfig, refreshGmailStatus, refreshSlackStatus])
|
||||||
|
|
||||||
// Refresh statuses when popover opens or providers list changes
|
// Refresh statuses when popover opens or providers list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -227,7 +297,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||||
// Show detailed message for Google and Fireflies (includes sync info)
|
// Show detailed message for providers that sync in background
|
||||||
if (provider === 'google' || provider === 'fireflies-ai') {
|
if (provider === 'google' || provider === 'fireflies-ai') {
|
||||||
toast.success(`Connected to ${displayName}`, {
|
toast.success(`Connected to ${displayName}`, {
|
||||||
description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
|
description: 'Syncing your data in the background. This may take a few minutes before changes appear.',
|
||||||
|
|
@ -251,7 +321,19 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||||
const { toolkitSlug, success, error } = event
|
const { toolkitSlug, success, error } = event
|
||||||
|
|
||||||
if (toolkitSlug === 'slack') {
|
if (toolkitSlug === 'gmail') {
|
||||||
|
setGmailConnected(success)
|
||||||
|
setGmailConnecting(false)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Connected to Gmail', {
|
||||||
|
description: 'Syncing your emails in the background. This may take a few minutes.',
|
||||||
|
duration: 8000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error(error || 'Failed to connect to Gmail')
|
||||||
|
}
|
||||||
|
} else if (toolkitSlug === 'slack') {
|
||||||
setSlackConnected(success)
|
setSlackConnected(success)
|
||||||
setSlackConnecting(false)
|
setSlackConnecting(false)
|
||||||
|
|
||||||
|
|
@ -431,16 +513,55 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Email & Calendar Section - Google */}
|
{/* Email Section - Gmail via Composio */}
|
||||||
{providers.includes('google') && (
|
<div className="px-2 py-1.5">
|
||||||
<>
|
<span className="text-xs font-medium text-muted-foreground">Email</span>
|
||||||
<div className="px-2 py-1.5">
|
</div>
|
||||||
<span className="text-xs font-medium text-muted-foreground">Email & Calendar</span>
|
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Mail className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
{renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')}
|
<div className="flex flex-col min-w-0">
|
||||||
<Separator className="my-2" />
|
<span className="text-sm font-medium truncate">Gmail</span>
|
||||||
</>
|
{gmailLoading ? (
|
||||||
)}
|
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">Sync emails</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{gmailLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
) : gmailConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDisconnectGmail}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConnectGmail}
|
||||||
|
disabled={gmailConnecting}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{gmailConnecting ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Connect"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2" />
|
||||||
|
|
||||||
{/* Meeting Notes Section - Granola & Fireflies */}
|
{/* Meeting Notes Section - Granola & Fireflies */}
|
||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
|
|
@ -537,7 +658,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
||||||
open={composioApiKeyOpen}
|
open={composioApiKeyOpen}
|
||||||
onOpenChange={setComposioApiKeyOpen}
|
onOpenChange={setComposioApiKeyOpen}
|
||||||
onSubmit={handleComposioApiKeySubmit}
|
onSubmit={handleComposioApiKeySubmit}
|
||||||
isSubmitting={slackConnecting}
|
isSubmitting={gmailConnecting || slackConnecting}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||||
|
|
||||||
// Composio/Slack state
|
// Composio state (Gmail + Slack)
|
||||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||||
|
const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'gmail' | 'slack'>('gmail')
|
||||||
|
const [gmailConnected, setGmailConnected] = useState(false)
|
||||||
|
const [gmailLoading, setGmailLoading] = useState(true)
|
||||||
|
const [gmailConnecting, setGmailConnecting] = useState(false)
|
||||||
const [slackConnected, setSlackConnected] = useState(false)
|
const [slackConnected, setSlackConnected] = useState(false)
|
||||||
const [slackLoading, setSlackLoading] = useState(true)
|
const [slackLoading, setSlackLoading] = useState(true)
|
||||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||||
|
|
@ -101,6 +105,47 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Load Gmail connection status
|
||||||
|
const refreshGmailStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailLoading(true)
|
||||||
|
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' })
|
||||||
|
setGmailConnected(result.isConnected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Gmail status:', error)
|
||||||
|
setGmailConnected(false)
|
||||||
|
} finally {
|
||||||
|
setGmailLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Connect to Gmail via Composio
|
||||||
|
const startGmailConnect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setGmailConnecting(true)
|
||||||
|
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' })
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || 'Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Gmail:', error)
|
||||||
|
toast.error('Failed to connect to Gmail')
|
||||||
|
setGmailConnecting(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle Gmail connect button click
|
||||||
|
const handleConnectGmail = useCallback(async () => {
|
||||||
|
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||||
|
if (!configResult.configured) {
|
||||||
|
setComposioApiKeyTarget('gmail')
|
||||||
|
setComposioApiKeyOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await startGmailConnect()
|
||||||
|
}, [startGmailConnect])
|
||||||
|
|
||||||
// Load Slack connection status
|
// Load Slack connection status
|
||||||
const refreshSlackStatus = useCallback(async () => {
|
const refreshSlackStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -134,9 +179,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
|
|
||||||
// Connect to Slack via Composio (checks if configured first)
|
// Connect to Slack via Composio (checks if configured first)
|
||||||
const handleConnectSlack = useCallback(async () => {
|
const handleConnectSlack = useCallback(async () => {
|
||||||
// Check if Composio is configured
|
|
||||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||||
if (!configResult.configured) {
|
if (!configResult.configured) {
|
||||||
|
setComposioApiKeyTarget('slack')
|
||||||
setComposioApiKeyOpen(true)
|
setComposioApiKeyOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -149,20 +194,24 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||||
setComposioApiKeyOpen(false)
|
setComposioApiKeyOpen(false)
|
||||||
toast.success('Composio API key saved')
|
toast.success('Composio API key saved')
|
||||||
// Now start the Slack connection
|
if (composioApiKeyTarget === 'gmail') {
|
||||||
await startSlackConnect()
|
await startGmailConnect()
|
||||||
|
} else {
|
||||||
|
await startSlackConnect()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save Composio API key:', error)
|
console.error('Failed to save Composio API key:', error)
|
||||||
toast.error('Failed to save API key')
|
toast.error('Failed to save API key')
|
||||||
}
|
}
|
||||||
}, [startSlackConnect])
|
}, [composioApiKeyTarget, startGmailConnect, startSlackConnect])
|
||||||
|
|
||||||
// Check connection status for all providers
|
// Check connection status for all providers
|
||||||
const refreshAllStatuses = useCallback(async () => {
|
const refreshAllStatuses = useCallback(async () => {
|
||||||
// Refresh Granola
|
// Refresh Granola
|
||||||
refreshGranolaConfig()
|
refreshGranolaConfig()
|
||||||
|
|
||||||
// Refresh Slack status
|
// Refresh Composio connections
|
||||||
|
refreshGmailStatus()
|
||||||
refreshSlackStatus()
|
refreshSlackStatus()
|
||||||
|
|
||||||
// Refresh OAuth providers
|
// Refresh OAuth providers
|
||||||
|
|
@ -191,7 +240,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
)
|
)
|
||||||
|
|
||||||
setProviderStates(newStates)
|
setProviderStates(newStates)
|
||||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
}, [providers, refreshGranolaConfig, refreshGmailStatus, refreshSlackStatus])
|
||||||
|
|
||||||
// Refresh statuses when modal opens or providers list changes
|
// Refresh statuses when modal opens or providers list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -230,7 +279,16 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||||
const { toolkitSlug, success, error } = event
|
const { toolkitSlug, success, error } = event
|
||||||
|
|
||||||
if (toolkitSlug === 'slack') {
|
if (toolkitSlug === 'gmail') {
|
||||||
|
setGmailConnected(success)
|
||||||
|
setGmailConnecting(false)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Connected to Gmail')
|
||||||
|
} else {
|
||||||
|
toast.error(error || 'Failed to connect to Gmail')
|
||||||
|
}
|
||||||
|
} else if (toolkitSlug === 'slack') {
|
||||||
setSlackConnected(success)
|
setSlackConnected(success)
|
||||||
setSlackConnecting(false)
|
setSlackConnecting(false)
|
||||||
|
|
||||||
|
|
@ -377,6 +435,48 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Render Gmail row (Composio)
|
||||||
|
const renderGmailRow = () => (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Mail className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate">Gmail</span>
|
||||||
|
{gmailLoading ? (
|
||||||
|
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">Sync emails</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
{gmailLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
) : gmailConnected ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
<span>Connected</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConnectGmail}
|
||||||
|
disabled={gmailConnecting}
|
||||||
|
>
|
||||||
|
{gmailConnecting ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Connect"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
// Render Slack row
|
// Render Slack row
|
||||||
const renderSlackRow = () => (
|
const renderSlackRow = () => (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||||
|
|
@ -470,15 +570,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Email & Calendar Section */}
|
{/* Email Section - Gmail via Composio */}
|
||||||
{providers.includes('google') && (
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="px-3">
|
||||||
<div className="px-3">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email</span>
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email & Calendar</span>
|
|
||||||
</div>
|
|
||||||
{renderOAuthProvider('google', 'Google', <Mail className="size-5" />, 'Sync emails and calendar events')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{renderGmailRow()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Meeting Notes Section */}
|
{/* Meeting Notes Section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -513,7 +611,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
|
|
||||||
// Step 2: Completion
|
// Step 2: Completion
|
||||||
const CompletionStep = () => {
|
const CompletionStep = () => {
|
||||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
|
const hasConnections = connectedProviders.length > 0 || gmailConnected || granolaEnabled || slackConnected
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
|
|
@ -536,10 +634,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
<div className="rounded-lg border bg-muted/50 p-4">
|
<div className="rounded-lg border bg-muted/50 p-4">
|
||||||
<p className="text-sm font-medium mb-2">Connected accounts:</p>
|
<p className="text-sm font-medium mb-2">Connected accounts:</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{connectedProviders.includes('google') && (
|
{gmailConnected && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<CheckCircle2 className="size-4 text-green-600" />
|
<CheckCircle2 className="size-4 text-green-600" />
|
||||||
<span>Google (Email & Calendar)</span>
|
<span>Gmail (Email)</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{connectedProviders.includes('fireflies-ai') && (
|
{connectedProviders.includes('fireflies-ai') && (
|
||||||
|
|
@ -578,7 +676,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||||
open={composioApiKeyOpen}
|
open={composioApiKeyOpen}
|
||||||
onOpenChange={setComposioApiKeyOpen}
|
onOpenChange={setComposioApiKeyOpen}
|
||||||
onSubmit={handleComposioApiKeySubmit}
|
onSubmit={handleComposioApiKeySubmit}
|
||||||
isSubmitting={slackConnecting}
|
isSubmitting={gmailConnecting || slackConnecting}
|
||||||
/>
|
/>
|
||||||
<Dialog open={open} onOpenChange={() => {}}>
|
<Dialog open={open} onOpenChange={() => {}}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { google, gmail_v1 as gmail } from 'googleapis';
|
|
||||||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||||
import { OAuth2Client } from 'google-auth-library';
|
|
||||||
import { WorkDir } from '../config/config.js';
|
import { WorkDir } from '../config/config.js';
|
||||||
import { GoogleClientFactory } from './google-client-factory.js';
|
import { executeAction } from '../composio/client.js';
|
||||||
|
import { composioAccountsRepo } from '../composio/repo.js';
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||||
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
|
const LOOKBACK_DAYS = 7;
|
||||||
|
|
||||||
const nhm = new NodeHtmlMarkdown();
|
const nhm = new NodeHtmlMarkdown();
|
||||||
|
|
||||||
|
|
@ -43,241 +42,209 @@ function cleanFilename(name: string): string {
|
||||||
return name.replace(/[\\/*?:":<>|]/g, "").substring(0, 100).trim();
|
return name.replace(/[\\/*?:":<>|]/g, "").substring(0, 100).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeBase64(data: string): string {
|
// --- State Management ---
|
||||||
return Buffer.from(data, 'base64').toString('utf-8');
|
|
||||||
|
interface SyncState {
|
||||||
|
last_sync: string; // ISO string — human-readable, source of truth
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBody(payload: gmail.Schema$MessagePart): string {
|
function loadState(stateFile: string): SyncState | null {
|
||||||
let body = "";
|
if (fs.existsSync(stateFile)) {
|
||||||
if (payload.parts) {
|
try {
|
||||||
for (const part of payload.parts) {
|
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||||
if (part.mimeType === 'text/plain' && part.body && part.body.data) {
|
if (data.last_sync) {
|
||||||
const text = decodeBase64(part.body.data);
|
return { last_sync: data.last_sync };
|
||||||
// Strip quoted lines
|
|
||||||
const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>'));
|
|
||||||
body += cleanLines.join('\n');
|
|
||||||
} else if (part.mimeType === 'text/html' && part.body && part.body.data) {
|
|
||||||
const html = decodeBase64(part.body.data);
|
|
||||||
const md = nhm.translate(html);
|
|
||||||
// Simple quote stripping for MD
|
|
||||||
const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>'));
|
|
||||||
body += cleanLines.join('\n');
|
|
||||||
} else if (part.parts) {
|
|
||||||
body += getBody(part);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Gmail] Failed to load state:', e);
|
||||||
}
|
}
|
||||||
} else if (payload.body && payload.body.data) {
|
|
||||||
const data = decodeBase64(payload.body.data);
|
|
||||||
if (payload.mimeType === 'text/html') {
|
|
||||||
const md = nhm.translate(data);
|
|
||||||
body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
|
||||||
} else {
|
|
||||||
body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
|
|
||||||
const filename = part.filename;
|
|
||||||
const attId = part.body?.attachmentId;
|
|
||||||
if (!filename || !attId) return null;
|
|
||||||
|
|
||||||
const safeName = `${msgId}_${cleanFilename(filename)}`;
|
|
||||||
const filePath = path.join(attachmentsDir, safeName);
|
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) return safeName;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await gmail.users.messages.attachments.get({
|
|
||||||
userId,
|
|
||||||
messageId: msgId,
|
|
||||||
id: attId
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = res.data.data;
|
|
||||||
if (data) {
|
|
||||||
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
|
|
||||||
console.log(`Saved attachment: ${safeName}`);
|
|
||||||
return safeName;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error saving attachment ${filename}:`, e);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveState(stateFile: string, lastSync: string): void {
|
||||||
|
const state: SyncState = {
|
||||||
|
last_sync: lastSync,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to parse a date string into a Date. Returns null if unparseable.
|
||||||
|
*/
|
||||||
|
function tryParseDate(dateStr: string): Date | null {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEpochSeconds(isoString: string): number {
|
||||||
|
return Math.floor(new Date(isoString).getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message Parsing ---
|
||||||
|
|
||||||
|
interface ParsedMessage {
|
||||||
|
from: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageData(messageData: Record<string, unknown>): ParsedMessage {
|
||||||
|
const headers = messageData.payload && typeof messageData.payload === 'object'
|
||||||
|
? (messageData.payload as Record<string, unknown>).headers as Array<{ name: string; value: string }> | undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown');
|
||||||
|
const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown');
|
||||||
|
const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)');
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
// Try to extract body from payload structure (Gmail API format)
|
||||||
|
if (messageData.payload && typeof messageData.payload === 'object') {
|
||||||
|
body = extractBodyFromPayload(messageData.payload as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try snippet or body fields
|
||||||
|
if (!body) {
|
||||||
|
if (typeof messageData.body === 'string') {
|
||||||
|
body = messageData.body;
|
||||||
|
} else if (typeof messageData.snippet === 'string') {
|
||||||
|
body = messageData.snippet;
|
||||||
|
} else if (typeof messageData.text === 'string') {
|
||||||
|
body = messageData.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert HTML to markdown if body looks like HTML
|
||||||
|
if (body && (body.includes('<html') || body.includes('<div') || body.includes('<p'))) {
|
||||||
|
body = nhm.translate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip quoted lines
|
||||||
|
if (body) {
|
||||||
|
body = body.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from, date, subject, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBodyFromPayload(payload: Record<string, unknown>): string {
|
||||||
|
const parts = payload.parts as Array<Record<string, unknown>> | undefined;
|
||||||
|
|
||||||
|
if (parts) {
|
||||||
|
for (const part of parts) {
|
||||||
|
const mimeType = part.mimeType as string | undefined;
|
||||||
|
const bodyData = part.body && typeof part.body === 'object'
|
||||||
|
? (part.body as Record<string, unknown>).data as string | undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) {
|
||||||
|
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||||
|
if (mimeType === 'text/html') {
|
||||||
|
return nhm.translate(decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into nested parts
|
||||||
|
if (part.parts) {
|
||||||
|
const result = extractBodyFromPayload(part as Record<string, unknown>);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-part message
|
||||||
|
const bodyData = payload.body && typeof payload.body === 'object'
|
||||||
|
? (payload.body as Record<string, unknown>).data as string | undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (bodyData) {
|
||||||
|
const decoded = Buffer.from(bodyData, 'base64').toString('utf-8');
|
||||||
|
const mimeType = payload.mimeType as string | undefined;
|
||||||
|
if (mimeType === 'text/html') {
|
||||||
|
return nhm.translate(decoded);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Sync Logic ---
|
// --- Sync Logic ---
|
||||||
|
|
||||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) {
|
/**
|
||||||
const gmail = google.gmail({ version: 'v1', auth });
|
* Process a thread and write its .md file.
|
||||||
|
* Returns the newest message date (as ISO string) found in the thread, or null.
|
||||||
|
*/
|
||||||
|
async function processThread(connectedAccountId: string, threadId: string, syncDir: string): Promise<string | null> {
|
||||||
|
let threadResult;
|
||||||
try {
|
try {
|
||||||
const res = await gmail.users.threads.get({ userId: 'me', id: threadId });
|
threadResult = await executeAction(
|
||||||
const thread = res.data;
|
'GMAIL_FETCH_MESSAGE_BY_THREAD_ID',
|
||||||
const messages = thread.messages;
|
connectedAccountId,
|
||||||
|
{ thread_id: threadId, user_id: 'me' }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!messages || messages.length === 0) return;
|
if (!threadResult.success || !threadResult.data) {
|
||||||
|
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Subject from first message
|
const data = threadResult.data as Record<string, unknown>;
|
||||||
const firstHeader = messages[0].payload?.headers;
|
const messages = data.messages as Array<Record<string, unknown>> | undefined;
|
||||||
const subject = firstHeader?.find(h => h.name === 'Subject')?.value || '(No Subject)';
|
|
||||||
|
|
||||||
let mdContent = `# ${subject}\n\n`;
|
let newestDate: Date | null = null;
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
// Single message response
|
||||||
|
const parsed = parseMessageData(data);
|
||||||
|
const mdContent = `# ${parsed.subject}\n\n` +
|
||||||
|
`**Thread ID:** ${threadId}\n` +
|
||||||
|
`**Message Count:** 1\n\n---\n\n` +
|
||||||
|
`### From: ${parsed.from}\n` +
|
||||||
|
`**Date:** ${parsed.date}\n\n` +
|
||||||
|
`${parsed.body}\n\n---\n\n`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||||
|
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
||||||
|
newestDate = tryParseDate(parsed.date);
|
||||||
|
} else {
|
||||||
|
// Multi-message thread
|
||||||
|
const firstParsed = parseMessageData(messages[0]);
|
||||||
|
let mdContent = `# ${firstParsed.subject}\n\n`;
|
||||||
mdContent += `**Thread ID:** ${threadId}\n`;
|
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||||
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const msgId = msg.id!;
|
const parsed = parseMessageData(msg);
|
||||||
const headers = msg.payload?.headers || [];
|
mdContent += `### From: ${parsed.from}\n`;
|
||||||
const from = headers.find(h => h.name === 'From')?.value || 'Unknown';
|
mdContent += `**Date:** ${parsed.date}\n\n`;
|
||||||
const date = headers.find(h => h.name === 'Date')?.value || 'Unknown';
|
mdContent += `${parsed.body}\n\n`;
|
||||||
|
mdContent += `---\n\n`;
|
||||||
|
|
||||||
mdContent += `### From: ${from}\n`;
|
const msgDate = tryParseDate(parsed.date);
|
||||||
mdContent += `**Date:** ${date}\n\n`;
|
if (msgDate && (!newestDate || msgDate > newestDate)) {
|
||||||
|
newestDate = msgDate;
|
||||||
if (msg.payload) {
|
|
||||||
const body = getBody(msg.payload);
|
|
||||||
mdContent += `${body}\n\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachments
|
|
||||||
const parts: gmail.Schema$MessagePart[] = [];
|
|
||||||
const traverseParts = (pList: gmail.Schema$MessagePart[]) => {
|
|
||||||
for (const p of pList) {
|
|
||||||
parts.push(p);
|
|
||||||
if (p.parts) traverseParts(p.parts);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (msg.payload?.parts) traverseParts(msg.payload.parts);
|
|
||||||
|
|
||||||
let attachmentsFound = false;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.filename && part.body?.attachmentId) {
|
|
||||||
const savedName = await saveAttachment(gmail, 'me', msgId, part, attachmentsDir);
|
|
||||||
if (savedName) {
|
|
||||||
if (!attachmentsFound) {
|
|
||||||
mdContent += "**Attachments:**\n";
|
|
||||||
attachmentsFound = true;
|
|
||||||
}
|
|
||||||
mdContent += `- [${part.filename}](attachments/${savedName})\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mdContent += "\n---\n\n";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||||
console.log(`Synced Thread: ${subject} (${threadId})`);
|
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing thread ${threadId}:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function loadState(stateFile: string): { historyId?: string } {
|
if (!newestDate) return null;
|
||||||
if (fs.existsSync(stateFile)) {
|
// Add 1 second so the `after:` query (epoch-second granularity) excludes this email next sync
|
||||||
return JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
return new Date(newestDate.getTime() + 1000).toISOString();
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveState(historyId: string, stateFile: string) {
|
|
||||||
fs.writeFileSync(stateFile, JSON.stringify({
|
|
||||||
historyId,
|
|
||||||
last_sync: new Date().toISOString()
|
|
||||||
}, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
|
|
||||||
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
|
||||||
const gmail = google.gmail({ version: 'v1', auth });
|
|
||||||
|
|
||||||
const pastDate = new Date();
|
|
||||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
|
||||||
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
|
|
||||||
|
|
||||||
// Get History ID
|
|
||||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
||||||
const currentHistoryId = profile.data.historyId!;
|
|
||||||
|
|
||||||
let pageToken: string | undefined;
|
|
||||||
do {
|
|
||||||
const res = await gmail.users.threads.list({
|
|
||||||
userId: 'me',
|
|
||||||
q: `after:${dateQuery}`,
|
|
||||||
pageToken
|
|
||||||
});
|
|
||||||
|
|
||||||
const threads = res.data.threads;
|
|
||||||
if (threads) {
|
|
||||||
for (const thread of threads) {
|
|
||||||
await processThread(auth, thread.id!, syncDir, attachmentsDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pageToken = res.data.nextPageToken ?? undefined;
|
|
||||||
} while (pageToken);
|
|
||||||
|
|
||||||
saveState(currentHistoryId, stateFile);
|
|
||||||
console.log("Full sync complete.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
|
|
||||||
console.log(`Checking updates since historyId ${startHistoryId}...`);
|
|
||||||
const gmail = google.gmail({ version: 'v1', auth });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await gmail.users.history.list({
|
|
||||||
userId: 'me',
|
|
||||||
startHistoryId,
|
|
||||||
historyTypes: ['messageAdded']
|
|
||||||
});
|
|
||||||
|
|
||||||
const changes = res.data.history;
|
|
||||||
if (!changes || changes.length === 0) {
|
|
||||||
console.log("No new changes.");
|
|
||||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
||||||
saveState(profile.data.historyId!, stateFile);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Found ${changes.length} history records.`);
|
|
||||||
const threadIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const record of changes) {
|
|
||||||
if (record.messagesAdded) {
|
|
||||||
for (const item of record.messagesAdded) {
|
|
||||||
if (item.message?.threadId) {
|
|
||||||
threadIds.add(item.message.threadId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tid of threadIds) {
|
|
||||||
await processThread(auth, tid, syncDir, attachmentsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
||||||
saveState(profile.data.historyId!, stateFile);
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const e = error as { response?: { status?: number } };
|
|
||||||
if (e.response?.status === 404) {
|
|
||||||
console.log("History ID expired. Falling back to full sync.");
|
|
||||||
await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
|
||||||
} else {
|
|
||||||
console.error("Error during partial sync:", error);
|
|
||||||
// If 401, clear tokens to force re-auth next run
|
|
||||||
if (e.response?.status === 401) {
|
|
||||||
console.log("401 Unauthorized, clearing cache");
|
|
||||||
GoogleClientFactory.clearCache();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performSync() {
|
async function performSync() {
|
||||||
const LOOKBACK_DAYS = 30; // Default to 1 month
|
|
||||||
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
|
||||||
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
|
||||||
|
|
||||||
|
|
@ -285,51 +252,127 @@ async function performSync() {
|
||||||
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true });
|
||||||
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const account = composioAccountsRepo.getAccount('gmail');
|
||||||
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
|
console.log('[Gmail] Gmail not connected via Composio. Skipping sync.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedAccountId = account.id;
|
||||||
|
|
||||||
|
// Determine query timestamp
|
||||||
|
const state = loadState(STATE_FILE);
|
||||||
|
let afterEpochSeconds: number;
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
afterEpochSeconds = toEpochSeconds(state.last_sync);
|
||||||
|
console.log(`[Gmail] Syncing messages since ${state.last_sync}...`);
|
||||||
|
} else {
|
||||||
|
const pastDate = new Date();
|
||||||
|
pastDate.setDate(pastDate.getDate() - LOOKBACK_DAYS);
|
||||||
|
afterEpochSeconds = Math.floor(pastDate.getTime() / 1000);
|
||||||
|
console.log(`[Gmail] First sync - fetching last ${LOOKBACK_DAYS} days...`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await GoogleClientFactory.getClient();
|
// List threads since last sync (lightweight - returns IDs only)
|
||||||
if (!auth) {
|
const allThreadIds: string[] = [];
|
||||||
console.log("No valid OAuth credentials available.");
|
let pageToken: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
query: `after:${afterEpochSeconds}`,
|
||||||
|
max_results: 20,
|
||||||
|
user_id: 'me',
|
||||||
|
};
|
||||||
|
if (pageToken) {
|
||||||
|
params.page_token = pageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeAction(
|
||||||
|
'GMAIL_LIST_THREADS',
|
||||||
|
connectedAccountId,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.error('[Gmail] Failed to list threads:', result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data as Record<string, unknown>;
|
||||||
|
const threads = data.threads as Array<Record<string, unknown>> | undefined;
|
||||||
|
|
||||||
|
if (threads && threads.length > 0) {
|
||||||
|
for (const thread of threads) {
|
||||||
|
const threadId = thread.id as string | undefined;
|
||||||
|
if (threadId) {
|
||||||
|
allThreadIds.push(threadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = data.nextPageToken as string | undefined;
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
if (allThreadIds.length === 0) {
|
||||||
|
console.log('[Gmail] No new threads.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Authorization successful. Starting sync...");
|
console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`);
|
||||||
|
|
||||||
const state = loadState(STATE_FILE);
|
// Reverse so we process oldest first. Gmail returns newest first,
|
||||||
if (!state.historyId) {
|
// so processing in reverse lets the high-water mark advance
|
||||||
console.log("No history ID found, starting full sync...");
|
// chronologically — safe to save state after each thread.
|
||||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
allThreadIds.reverse();
|
||||||
} else {
|
|
||||||
console.log("History ID found, starting partial sync...");
|
// Process each thread, saving state after each one with the
|
||||||
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
// newest email date seen so far (high-water mark).
|
||||||
|
let highWaterMark: string | null = state?.last_sync ?? null;
|
||||||
|
let processedCount = 0;
|
||||||
|
for (const threadId of allThreadIds) {
|
||||||
|
try {
|
||||||
|
const newestInThread = await processThread(connectedAccountId, threadId, SYNC_DIR);
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
// Advance high-water mark if this thread has a newer email
|
||||||
|
if (newestInThread) {
|
||||||
|
if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) {
|
||||||
|
highWaterMark = newestInThread;
|
||||||
|
}
|
||||||
|
saveState(STATE_FILE, highWaterMark);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Sync completed.");
|
console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during sync:", error);
|
console.error('[Gmail] Error during sync:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
console.log("Starting Gmail Sync (TS)...");
|
console.log('[Gmail] Starting Gmail Sync (Composio)...');
|
||||||
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
console.log(`[Gmail] Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
// Check if credentials are available with required scopes
|
const isConnected = composioAccountsRepo.isConnected('gmail');
|
||||||
const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
|
|
||||||
|
if (!isConnected) {
|
||||||
if (!hasCredentials) {
|
console.log('[Gmail] Gmail not connected via Composio. Sleeping...');
|
||||||
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");
|
|
||||||
} else {
|
} else {
|
||||||
// Perform one sync
|
|
||||||
await performSync();
|
await performSync();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in main loop:", error);
|
console.error('[Gmail] Error in main loop:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sleep for N minutes before next check (can be interrupted by triggerSync)
|
console.log(`[Gmail] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
|
||||||
console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
|
|
||||||
await interruptibleSleep(SYNC_INTERVAL_MS);
|
await interruptibleSleep(SYNC_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue